@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -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 +282 -14
  13. package/contracts/release-ledger.json +1233 -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/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -17,6 +17,12 @@ 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";
21
+ import {
22
+ readabilityCropTargets,
23
+ readabilityObservationOk,
24
+ readabilityProbeSource,
25
+ } from "./polish-readability.mjs";
20
26
  import {
21
27
  boundedPolishDeadline,
22
28
  POLISH_BROWSER_CELL_DEADLINE_MS,
@@ -511,6 +517,393 @@ async function collectMediaElements(page) {
511
517
  });
512
518
  }
513
519
 
520
+ // The <img> reader the media-weight image probe runs after network
521
+ // observation closed, in an isolated world: every DOM method, getter and
522
+ // window property it uses is that world's own, so page scripts can neither
523
+ // change what it reads nor see that it ran. It walks the document in
524
+ // shadow-including tree order (an element, then its shadow tree, then its
525
+ // children), reaching open shadow roots through element.shadowRoot and
526
+ // closed ones through `closedRoots` (resolved into this world over CDP); it
527
+ // does not enter an iframe's document. It returns the <img> count,
528
+ // window.devicePixelRatio and, for the first `limits.images` <img>: the
529
+ // element path (the CSS child path from <body>, every step
530
+ // "tag:nth-of-type(n)", with a "#shadow-root" step after a shadow host),
531
+ // currentSrc (http(s) as is; any other scheme as the scheme alone), loading,
532
+ // complete, natural size, the rendered box in CSS px, computed object-fit and
533
+ // whether it is hidden (checkVisibility: it or a flat-tree ancestor is not
534
+ // displayed, or it is not visible). It reads only; it never scrolls and
535
+ // starts no load.
536
+ function readImageElements(limits, ...closedRoots) {
537
+ const closedRootOf = new Map(closedRoots.filter((root) => root?.host).map((root) => [root.host, root]));
538
+ const source = (value) => {
539
+ if (typeof value !== "string" || value === "") return { url: null, svg: false };
540
+ if (/^https?:/i.test(value)) return { url: value.length <= limits.urlLength ? value : "[url-too-long]", svg: false };
541
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
542
+ return { url: scheme ? `${scheme[1].toLowerCase()}:` : null, svg: /^data:image\/svg\+xml[;,]/i.test(value) };
543
+ };
544
+ const found = [];
545
+ let observedCount = 0;
546
+ // Work still to do, last first: an element to visit, or a parent (the
547
+ // document, an element or a shadow root) whose children are still to list.
548
+ const pending = [{ parent: document, path: "" }];
549
+ while (pending.length) {
550
+ const item = pending.pop();
551
+ if (item.element) {
552
+ if (item.element instanceof HTMLImageElement) {
553
+ observedCount += 1;
554
+ if (found.length < limits.images) found.push(item);
555
+ }
556
+ pending.push({ parent: item.element, path: item.path });
557
+ const shadowRoot = item.element.shadowRoot ?? closedRootOf.get(item.element);
558
+ if (shadowRoot) pending.push({ parent: shadowRoot, path: `${item.path}>#shadow-root` });
559
+ continue;
560
+ }
561
+ const children = [];
562
+ const counts = new Map();
563
+ for (let child = item.parent.firstElementChild; child; child = child.nextElementSibling) {
564
+ const index = (counts.get(child.tagName) ?? 0) + 1;
565
+ counts.set(child.tagName, index);
566
+ const tag = child.tagName.toLowerCase();
567
+ const path = child === document.body || child === document.documentElement
568
+ ? tag
569
+ : `${item.path ? `${item.path}>` : ""}${tag}:nth-of-type(${index})`;
570
+ children.push({ element: child, path });
571
+ }
572
+ for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
573
+ }
574
+ const images = found.map(({ element: image, path }) => {
575
+ const src = source(image.currentSrc);
576
+ const style = getComputedStyle(image);
577
+ const bounds = image.getBoundingClientRect();
578
+ return {
579
+ element_path: path,
580
+ current_src: src.url,
581
+ svg_data: src.svg,
582
+ loading: image.loading,
583
+ complete: image.complete,
584
+ natural: [image.naturalWidth, image.naturalHeight],
585
+ rendered: [bounds.width, bounds.height],
586
+ object_fit: style.objectFit,
587
+ hidden: !image.checkVisibility({ visibilityProperty: true }),
588
+ };
589
+ });
590
+ return { dpr: window.devicePixelRatio, observed_count: observedCount, images };
591
+ }
592
+
593
+ // The backend node ids of the main document's closed shadow roots, from a
594
+ // DOM.getDocument tree read with pierce. Open roots the read reaches itself
595
+ // and user-agent roots are not the page's. An iframe's document is entered
596
+ // only with `frameOwners`: a closed root inside it, which the main frame's
597
+ // world cannot resolve, is named by the main document's iframe that holds
598
+ // it (once per iframe).
599
+ function closedShadowRoots(root, { frameOwners = false } = {}) {
600
+ const found = [];
601
+ const pending = [[root, null]];
602
+ while (pending.length) {
603
+ const [node, owner] = pending.pop();
604
+ if (!node || typeof node !== "object") continue;
605
+ if (node.shadowRootType === "closed" && Number.isInteger(owner ?? node.backendNodeId)) found.push(owner ?? node.backendNodeId);
606
+ if (Array.isArray(node.shadowRoots)) pending.push(...node.shadowRoots.map((child) => [child, owner]));
607
+ if (Array.isArray(node.children)) pending.push(...node.children.map((child) => [child, owner]));
608
+ if (frameOwners && node.contentDocument) pending.push([node.contentDocument, owner ?? node.backendNodeId]);
609
+ }
610
+ return [...new Set(found)];
611
+ }
612
+
613
+ const BOUND_ENDED = Symbol("bound ended");
614
+ // Readability work in a cell ends this long before the adapter cell deadline,
615
+ // so a readability bound, not that deadline, is what stops it.
616
+ const READABILITY_CELL_HEADROOM_MS = 1_000;
617
+ const realProbeClock = Object.freeze({
618
+ now: () => performance.now(),
619
+ sleep: (ms) => new Promise((resolve) => {
620
+ const timer = setTimeout(resolve, ms);
621
+ timer.unref?.();
622
+ }),
623
+ });
624
+
625
+ function imageRead(value, imageCap) {
626
+ return value && typeof value === "object" && Number.isInteger(value.observed_count) && Array.isArray(value.images)
627
+ && value.images.length <= imageCap
628
+ ? value
629
+ : null;
630
+ }
631
+
632
+ // The media-weight image probe, run after collector.finish so it cannot
633
+ // delay the network window or add to the ledger. Its steps, in order: an
634
+ // isolated world (Page.createIsolatedWorld); the node tree (DOM.getDocument,
635
+ // piercing shadow roots) and each closed shadow root resolved into that world
636
+ // (DOM.resolveNode); the one read (Runtime.callFunctionOn of
637
+ // readImageElements in that world), which is the only source of the listing,
638
+ // geometry, computed style and device pixel ratio; and the Page.getFrameTree
639
+ // re-read. The probe's bound is the smaller of the cell bound and the run
640
+ // budget left. Every step races one deadline on `clock` at that bound, and
641
+ // none starts once it has passed. spent_ms, on `clock` from the first step to
642
+ // the return, is what the run budget is charged. The probe is cancelled when
643
+ // its bound ends it, on its timer or on `clock` whichever is first, or when it
644
+ // returns: every CDP command, and every step's result, checks that first, so
645
+ // a step that settles late issues nothing more. A probe its bound ends reads
646
+ // probe_timeout, or probe_budget_exhausted where the run budget left was the
647
+ // smaller bound, never complete; it names its images only when its read had
648
+ // returned. A cell that starts once the run budget is spent starts no step
649
+ // and reads probe_budget_exhausted with no images. The re-read discards the
650
+ // probe as document_context_changed when the main frame has another id or
651
+ // loaderId than page_load's own re-read found; so does a step that failed
652
+ // (its execution context destroyed).
653
+ // Returns { status, dpr, images, spent_ms }.
654
+ async function probeImageElements(options) {
655
+ const token = { cancelled: false };
656
+ try {
657
+ return await runImageProbe(options, token);
658
+ } finally {
659
+ token.cancelled = true;
660
+ }
661
+ }
662
+
663
+ // One probe's bound on `clock`, from `started`: the guard run before every
664
+ // command the probe issues and before it acts on any step's result (the probe
665
+ // is cancelled once the token is, or once the clock reaches the bound; the
666
+ // clock is read even when no timer has fired, so a step that settles past the
667
+ // bound ahead of a queued timer issues nothing more, and a passed bound
668
+ // cancels the token too), the guarded CDP send, and one step: started only
669
+ // inside the bound and raced against it. A step that settles past the bound
670
+ // reads BOUND_ENDED, not its result. `hardMs`, when given, is a second bound
671
+ // in real time from now (the adapter cell deadline's headroom), whatever
672
+ // `clock` is; `hardEnded()` tells whether it is the one that ended. Which
673
+ // bound ended is recorded when it ends: by the timer that fired, or by the
674
+ // clock read that found it passed (the real-time bound first), so a timer
675
+ // that fires before performance.now() reaches its end still names its bound.
676
+ function boundedProbeSteps({ clock, session, token, started, boundMs, hardMs = Infinity }) {
677
+ const boundEnd = started + boundMs;
678
+ const hardEnd = performance.now() + hardMs;
679
+ let endedBy = null;
680
+ const end = (by) => {
681
+ if (token.cancelled) return;
682
+ token.cancelled = true;
683
+ endedBy = by;
684
+ };
685
+ const hardEnded = () => endedBy === "hard";
686
+ const cancelled = () => {
687
+ if (performance.now() >= hardEnd) end("hard");
688
+ else if (clock.now() >= boundEnd) end("clock");
689
+ return token.cancelled;
690
+ };
691
+ const send = (method, params) => (cancelled()
692
+ ? Promise.reject(new Error("The probe was cancelled."))
693
+ : session.send(method, params));
694
+ const deadline = Promise.race([
695
+ clock.sleep(boundMs).then(() => end("clock")),
696
+ ...(Number.isFinite(hardMs) ? [realProbeClock.sleep(Math.max(0, hardMs)).then(() => end("hard"))] : []),
697
+ ]).then(() => BOUND_ENDED);
698
+ const step = async (start) => {
699
+ if (cancelled()) return BOUND_ENDED;
700
+ const settled = await Promise.race([Promise.resolve().then(start).then((value) => ({ value }), (error) => ({ error })), deadline]);
701
+ return cancelled() ? BOUND_ENDED : settled;
702
+ };
703
+ return { cancelled, hardEnded, send, step };
704
+ }
705
+
706
+ // The isolated-world read a probe makes, each step inside its bound: an
707
+ // isolated world on the main frame (Page.createIsolatedWorld); the node tree
708
+ // (DOM.getDocument, piercing shadow roots) and each closed shadow root
709
+ // resolved into that world (DOM.resolveNode); and the one read
710
+ // (Runtime.callFunctionOn of `functionDeclaration` in that world, with
711
+ // `leadingArgs` and then the closed roots, passed by object id so the
712
+ // function receives the root nodes themselves; with `frameOwners`, also the
713
+ // iframes holding closed roots, see closedShadowRoots). Returns BOUND_ENDED
714
+ // when the bound ended a step, null when a step failed (its execution
715
+ // context destroyed), or { value } of the read.
716
+ async function isolatedWorldRead({ steps, mainFrame, worldName, functionDeclaration, leadingArgs, frameOwners = false }) {
717
+ const { send, step } = steps;
718
+ const world = await step(() => send("Page.createIsolatedWorld", {
719
+ frameId: mainFrame.id,
720
+ worldName,
721
+ grantUniveralAccess: false,
722
+ }));
723
+ if (world === BOUND_ENDED) return BOUND_ENDED;
724
+ const contextId = world.value?.executionContextId;
725
+ if (world.error || !Number.isInteger(contextId)) return null;
726
+ const tree = await step(() => send("DOM.getDocument", { depth: -1, pierce: true }));
727
+ if (tree === BOUND_ENDED) return BOUND_ENDED;
728
+ if (tree.error) return null;
729
+ const roots = await step(() => Promise.all(closedShadowRoots(tree.value?.root, { frameOwners })
730
+ .map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
731
+ if (roots === BOUND_ENDED) return BOUND_ENDED;
732
+ const rootIds = roots.error ? null : roots.value.map((resolved) => resolved?.object?.objectId);
733
+ if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return null;
734
+ const evaluated = await step(() => send("Runtime.callFunctionOn", {
735
+ functionDeclaration,
736
+ executionContextId: contextId,
737
+ arguments: [...leadingArgs, ...rootIds.map((objectId) => ({ objectId }))],
738
+ returnByValue: true,
739
+ }));
740
+ if (evaluated === BOUND_ENDED) return BOUND_ENDED;
741
+ if (evaluated.error || evaluated.value?.exceptionDetails) return null;
742
+ return { value: evaluated.value?.result?.value };
743
+ }
744
+
745
+ // The Page.getFrameTree re-read inside the bound: BOUND_ENDED, or whether the
746
+ // main frame still has `mainFrame`'s id and loaderId (false when it does not,
747
+ // or the re-read failed).
748
+ async function sameMainDocument({ steps, mainFrame }) {
749
+ const reread = await steps.step(() => steps.send("Page.getFrameTree"));
750
+ if (reread === BOUND_ENDED) return BOUND_ENDED;
751
+ if (reread.error) return false;
752
+ const frame = reread.value?.frameTree?.frame;
753
+ return frame?.id === mainFrame.id && frame?.loaderId === mainFrame.loaderId;
754
+ }
755
+
756
+ async function runImageProbe({ session, mainFrame, documentContextChanged, probe }, token) {
757
+ const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
758
+ const limits = { images: probe.imageCap, urlLength: MAX_POLISH_CAPTURE_URL_LENGTH };
759
+ const started = clock.now();
760
+ const spent = () => Math.max(0, clock.now() - started);
761
+ let read = null;
762
+ const outcome = (status) => ({ status, dpr: read?.dpr ?? null, images: read?.images ?? [], spent_ms: spent() });
763
+ if (!(probe.remainingMs > 0)) return { ...outcome("probe_budget_exhausted"), spent_ms: 0 };
764
+ if (documentContextChanged || typeof mainFrame?.id !== "string") return outcome("document_context_changed");
765
+ const boundMs = Math.min(probe.cellBoundMs, probe.remainingMs);
766
+ // The status of a probe its bound ended.
767
+ const boundEnded = probe.remainingMs < probe.cellBoundMs ? "probe_budget_exhausted" : "probe_timeout";
768
+ const steps = boundedProbeSteps({ clock, session, token, started, boundMs });
769
+
770
+ const called = await isolatedWorldRead({
771
+ steps,
772
+ mainFrame,
773
+ worldName: "campaigns-os-polish-image-probe",
774
+ functionDeclaration: readImageElements.toString(),
775
+ leadingArgs: [{ value: limits }],
776
+ });
777
+ if (called === BOUND_ENDED) return outcome(boundEnded);
778
+ if (called === null) return outcome("document_context_changed");
779
+ read = imageRead(called.value, probe.imageCap);
780
+ if (!read) return outcome("document_context_changed");
781
+ const same = await sameMainDocument({ steps, mainFrame });
782
+ if (same === BOUND_ENDED) return outcome(boundEnded);
783
+ if (!same) return outcome("document_context_changed");
784
+ if (read.observed_count > probe.imageCap) return outcome("image_cap_reached");
785
+ return steps.cancelled() ? outcome(boundEnded) : outcome("complete");
786
+ }
787
+
788
+ // The readability probe of one cell (src/polish-readability.mjs), after the
789
+ // page has loaded, and the cell's crops after it. The probe is the image
790
+ // probe's isolated-world read of readabilityProbeSource and its main-frame
791
+ // re-read: a main frame with another id or loaderId than `mainFrame`, or a
792
+ // failed step, reads document_changed. Its bound is the smaller of the cell
793
+ // bound (READABILITY_PROBE_CELL_MS) and what is left of the run's probe and
794
+ // added budgets, on `clock`, and it also ends at `cellLeftMs` (the adapter
795
+ // cell deadline's headroom, in real time); a probe its bound ends reads
796
+ // probe_timeout, or run_budget_exhausted where a run budget was the smaller
797
+ // bound. A measured cell then takes its crops (readabilityCropTargets) as
798
+ // viewport-clip screenshots, at most `cropsPerCell`, and reads the main frame
799
+ // once more, every one of those steps inside one crop bound: what is left of
800
+ // the run's crop and added budgets on `clock`, and of the cell headroom. A crop target outside the
801
+ // viewport reads outside_viewport; one past the count or the bound, or whose
802
+ // screenshot failed, crop_unavailable; so does every crop taken when the
803
+ // bound ends before the re-read. crop_ms runs from the first crop step to the
804
+ // end of the re-read.
805
+ // Returns { status, capped, coverage_gaps, elements, crops, probe_ms, crop_ms }.
806
+ async function probeReadabilityDocument(options) {
807
+ const token = { cancelled: false };
808
+ const cropToken = { cancelled: false };
809
+ try {
810
+ return await runReadabilityProbe(options, token, cropToken);
811
+ } finally {
812
+ token.cancelled = true;
813
+ cropToken.cancelled = true;
814
+ }
815
+ }
816
+
817
+ async function runReadabilityProbe({ session, mainFrame, documentContextChanged, probe }, token, cropToken) {
818
+ const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
819
+ const started = clock.now();
820
+ const spent = () => Math.max(0, clock.now() - started);
821
+ const unmeasured = (status) => ({ status, capped: false, coverage_gaps: [], elements: [], crops: [], probe_ms: spent(), crop_ms: 0 });
822
+ const runLeft = Math.min(probe.probeRemainingMs, probe.addedRemainingMs);
823
+ // The cell deadline's headroom, in real time.
824
+ const cellEndsAt = performance.now() + (Number.isFinite(probe.cellLeftMs) ? probe.cellLeftMs : Infinity);
825
+ const cellLeft = () => cellEndsAt - performance.now();
826
+ if (!(runLeft > 0)) return { ...unmeasured("run_budget_exhausted"), probe_ms: 0 };
827
+ if (!(cellLeft() > 0)) return { ...unmeasured("probe_timeout"), probe_ms: 0 };
828
+ if (documentContextChanged || typeof mainFrame?.id !== "string") return unmeasured("document_changed");
829
+ const boundMs = Math.min(probe.cellBoundMs, runLeft);
830
+ const steps = boundedProbeSteps({ clock, session, token, started, boundMs, hardMs: cellLeft() });
831
+ // The status of a probe its bound ended.
832
+ const bound = () => (!steps.hardEnded() && runLeft < probe.cellBoundMs ? "run_budget_exhausted" : "probe_timeout");
833
+
834
+ const called = await isolatedWorldRead({
835
+ steps,
836
+ mainFrame,
837
+ worldName: "campaigns-os-polish-readability-probe",
838
+ functionDeclaration: readabilityProbeSource(),
839
+ leadingArgs: [{ value: { elements: probe.elementCap } }],
840
+ frameOwners: true,
841
+ });
842
+ if (called === BOUND_ENDED) return unmeasured(bound());
843
+ if (called === null || !readabilityObservationOk(called.value)) return unmeasured("document_changed");
844
+ const same = await sameMainDocument({ steps, mainFrame });
845
+ if (same === BOUND_ENDED) return unmeasured(bound());
846
+ if (!same) return unmeasured("document_changed");
847
+ if (steps.cancelled()) return unmeasured(bound());
848
+ const read = called.value;
849
+ const probeMs = spent();
850
+ if (read.status !== "measured") return { ...unmeasured(read.status), probe_ms: probeMs };
851
+
852
+ // Crops, after every measurement in the cell.
853
+ const cropStarted = clock.now();
854
+ const cropSpent = () => Math.max(0, clock.now() - cropStarted);
855
+ const cropLeft = Math.min(probe.cropRemainingMs, probe.addedRemainingMs - probeMs);
856
+ const crops = [];
857
+ const elements = read.elements;
858
+ const viewport = read.viewport || {};
859
+ const targets = readabilityCropTargets(elements);
860
+ const cropSteps = cropLeft > 0 && cellLeft() > 0 ? boundedProbeSteps({ clock, session, token: cropToken, started: cropStarted, boundMs: cropLeft, hardMs: cellLeft() }) : null;
861
+ for (const [taken, index] of targets.entries()) {
862
+ const element = elements[index];
863
+ const rect = element.rect || {};
864
+ const x = Math.max(0, rect.x);
865
+ const y = Math.max(0, rect.y);
866
+ const width = Math.min(viewport.width, rect.x + rect.width) - x;
867
+ const height = Math.min(viewport.height, rect.y + rect.height) - y;
868
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
869
+ element.crop_reason = "outside_viewport";
870
+ continue;
871
+ }
872
+ if (taken >= probe.cropsPerCell || !cropSteps || cropSteps.cancelled()) {
873
+ element.crop_reason = "crop_unavailable";
874
+ continue;
875
+ }
876
+ const shot = await cropSteps.step(() => cropSteps.send("Page.captureScreenshot", {
877
+ format: "png",
878
+ clip: { x, y, width, height, scale: 1 },
879
+ captureBeyondViewport: false,
880
+ }));
881
+ if (shot === BOUND_ENDED || shot.error || typeof shot.value?.data !== "string" || shot.value.data === "") {
882
+ element.crop_reason = "crop_unavailable";
883
+ continue;
884
+ }
885
+ crops.push({ element: index, data: shot.value.data });
886
+ }
887
+ // The crops are of the measured document only: the re-read is a crop step.
888
+ // When the bound ends first, no crop taken can be shown to be of it.
889
+ const cropped = crops.length ? await sameMainDocument({ steps: cropSteps, mainFrame }) : true;
890
+ const cropMs = targets.length ? cropSpent() : 0;
891
+ if (cropped === BOUND_ENDED) {
892
+ for (const crop of crops.splice(0)) elements[crop.element].crop_reason = "crop_unavailable";
893
+ } else if (!cropped) {
894
+ return { ...unmeasured("document_changed"), probe_ms: probeMs, crop_ms: cropMs };
895
+ }
896
+ return {
897
+ status: "measured",
898
+ capped: read.capped,
899
+ coverage_gaps: read.coverage_gaps,
900
+ elements,
901
+ crops,
902
+ probe_ms: probeMs,
903
+ crop_ms: cropMs,
904
+ };
905
+ }
906
+
514
907
  function preferredResolvedValue(finalValue, initialValue) {
515
908
  return typeof finalValue === "string" && finalValue !== "" ? finalValue : initialValue;
516
909
  }
@@ -675,164 +1068,290 @@ export async function createPolishBrowserAdapter({
675
1068
  let closed = false;
676
1069
  let poisonCode = null;
677
1070
  let closePromise = null;
678
- return {
679
- async captureRoute({ url, viewport, signal } = {}) {
680
- if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
681
- if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
682
- if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
683
- if (typeof url !== "string" || url.length > MAX_POLISH_CAPTURE_URL_LENGTH) {
684
- throw new Error("Campaigns OS polish capture requires a bounded HTTP(S) capture URL.");
1071
+ // One cell in a fresh browser context: the context (viewport, service
1072
+ // workers blocked, the auth cookies), its page and CDP session, `work`
1073
+ // under the adapter cell deadline, and the context closed under the cleanup
1074
+ // bound. A timeout or a failed cleanup poisons the adapter for every later
1075
+ // cell. `work` also gets `cellLeftMs()`, the time left before the cell
1076
+ // deadline less the readability headroom. With `setup` ({ step, ended }),
1077
+ // each setup call is a `step` of the caller's bound (boundedProbeSteps); a
1078
+ // bound that ends during setup makes the cell return `ended()` instead of
1079
+ // running `work`, and a context created after that is closed.
1080
+ const inCaptureContext = async ({ url, viewport, signal, setup = null }, work) => {
1081
+ if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
1082
+ if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
1083
+ if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
1084
+ if (typeof url !== "string" || url.length > MAX_POLISH_CAPTURE_URL_LENGTH) {
1085
+ throw new Error("Campaigns OS polish capture requires a bounded HTTP(S) capture URL.");
1086
+ }
1087
+ if (!Number.isInteger(viewport?.width) || viewport.width <= 0
1088
+ || !Number.isInteger(viewport?.height) || viewport.height <= 0) {
1089
+ throw new Error("Campaigns OS polish capture requires a positive integer viewport.");
1090
+ }
1091
+ let context;
1092
+ let session;
1093
+ let timedOut = false;
1094
+ let detachPromise = null;
1095
+ let contextClosePromise = null;
1096
+ const cleanupResources = () => {
1097
+ if (typeof session?.detach === "function" && !detachPromise) {
1098
+ detachPromise = Promise.resolve().then(() => session.detach());
1099
+ // Context closure is authoritative. CDP detach commonly rejects when
1100
+ // that same close wins the race, so observe but never await or expose it.
1101
+ void detachPromise.catch(() => {});
685
1102
  }
686
- if (!Number.isInteger(viewport?.width) || viewport.width <= 0
687
- || !Number.isInteger(viewport?.height) || viewport.height <= 0) {
688
- throw new Error("Campaigns OS polish capture requires a positive integer viewport.");
1103
+ if (typeof context?.close === "function" && !contextClosePromise) {
1104
+ contextClosePromise = Promise.resolve().then(() => context.close());
689
1105
  }
690
- let context;
691
- let session;
692
- let timedOut = false;
693
- let detachPromise = null;
694
- let contextClosePromise = null;
695
- const cleanupResources = () => {
696
- if (typeof session?.detach === "function" && !detachPromise) {
697
- detachPromise = Promise.resolve().then(() => session.detach());
698
- // Context closure is authoritative. CDP detach commonly rejects when
699
- // that same close wins the race, so observe but never await or expose it.
700
- void detachPromise.catch(() => {});
701
- }
702
- if (typeof context?.close === "function" && !contextClosePromise) {
703
- contextClosePromise = Promise.resolve().then(() => context.close());
1106
+ if (!context) return Promise.resolve();
1107
+ if (!contextClosePromise) return Promise.reject(polishProducerCleanupError());
1108
+ return contextClosePromise.catch(() => { throw polishProducerCleanupError(); });
1109
+ };
1110
+ const assertActive = () => {
1111
+ if (!timedOut) return;
1112
+ poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1113
+ void cleanupResources().catch(() => {});
1114
+ throw polishProducerTimeoutError();
1115
+ };
1116
+ const awaitActive = async (promise) => {
1117
+ try {
1118
+ const value = await promise;
1119
+ assertActive();
1120
+ return value;
1121
+ } catch (error) {
1122
+ assertActive();
1123
+ throw error;
1124
+ }
1125
+ };
1126
+ const triggerTimeout = () => {
1127
+ timedOut = true;
1128
+ poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1129
+ void cleanupResources().catch(() => {});
1130
+ };
1131
+ if (signal?.aborted) triggerTimeout();
1132
+ else if (typeof signal?.addEventListener === "function") {
1133
+ signal.addEventListener("abort", triggerTimeout, { once: true });
1134
+ }
1135
+ let setupEnded = false;
1136
+ // One setup call: under the caller's bound when there is one.
1137
+ const settle = async (start) => {
1138
+ if (!setup) return awaitActive(start());
1139
+ const settled = await setup.step(start);
1140
+ assertActive();
1141
+ if (settled === BOUND_ENDED) {
1142
+ setupEnded = true;
1143
+ throw BOUND_ENDED;
1144
+ }
1145
+ if (settled.error) throw settled.error;
1146
+ return settled.value;
1147
+ };
1148
+ const cellStartedAt = performance.now();
1149
+ const cellLeftMs = () => boundedCellDeadlineMs - (performance.now() - cellStartedAt) - READABILITY_CELL_HEADROOM_MS;
1150
+ let observation;
1151
+ let operationError = null;
1152
+ try {
1153
+ observation = await runWithPolishProducerDeadline(async () => {
1154
+ assertActive();
1155
+ // The context is kept as soon as it exists, so cleanup closes it; one
1156
+ // that arrives after the setup bound ended is closed at once.
1157
+ const newContext = () => Promise.resolve(browser.newContext({
1158
+ viewport: { width: viewport.width, height: viewport.height },
1159
+ serviceWorkers: "block",
1160
+ })).then((created) => {
1161
+ if (setupEnded) void Promise.resolve().then(() => created?.close()).catch(() => {});
1162
+ else context = created;
1163
+ return created;
1164
+ });
1165
+ if (setup) await settle(newContext);
1166
+ else await newContext();
1167
+ assertActive();
1168
+ if (authCookies.length > 0) {
1169
+ const origin = captureOrigin(url);
1170
+ // Fixed message: a URL containing credentials or query data is never echoed.
1171
+ if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
1172
+ await settle(() => context.addCookies(authCookies.map((cookie) => ({ ...cookie, url: origin }))));
704
1173
  }
705
- if (!context) return Promise.resolve();
706
- if (!contextClosePromise) return Promise.reject(polishProducerCleanupError());
707
- return contextClosePromise.catch(() => { throw polishProducerCleanupError(); });
708
- };
709
- const assertActive = () => {
710
- if (!timedOut) return;
1174
+ const page = await settle(() => context.newPage());
1175
+ session = setup ? await settle(() => context.newCDPSession(page)) : await context.newCDPSession(page);
1176
+ assertActive();
1177
+ return work({ page, session, awaitActive, cellLeftMs });
1178
+ }, {
1179
+ timeoutMs: boundedCellDeadlineMs,
1180
+ onTimeout: triggerTimeout,
1181
+ signal,
1182
+ });
1183
+ } catch (error) {
1184
+ if (error === BOUND_ENDED && setup) observation = setup.ended();
1185
+ else operationError = error;
1186
+ if (error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
711
1187
  poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
712
- void cleanupResources().catch(() => {});
713
- throw polishProducerTimeoutError();
714
- };
715
- const awaitActive = async (promise) => {
1188
+ }
1189
+ } finally {
1190
+ if (typeof signal?.removeEventListener === "function") {
1191
+ signal.removeEventListener("abort", triggerTimeout);
1192
+ }
1193
+ }
1194
+ let cleanupError = null;
1195
+ try {
1196
+ await runWithPolishProducerDeadline(cleanupResources, { timeoutMs: boundedCleanupDeadlineMs });
1197
+ } catch (error) {
1198
+ cleanupError = error;
1199
+ }
1200
+ if (cleanupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
1201
+ poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1202
+ throw cleanupError;
1203
+ }
1204
+ if (operationError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
1205
+ poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1206
+ throw operationError;
1207
+ }
1208
+ if (cleanupError) {
1209
+ poisonCode = POLISH_PRODUCER_CLEANUP_ERROR_CODE;
1210
+ throw polishProducerCleanupError();
1211
+ }
1212
+ if (operationError) throw operationError;
1213
+ return observation;
1214
+ };
1215
+ return {
1216
+ // `imageProbe` ({ clock, remainingMs, cellBoundMs, imageCap }) opts the
1217
+ // cell into the media-weight image probe; the observation then carries
1218
+ // `imageProbe` beside the page-load fields. `readabilityProbe` ({ clock,
1219
+ // cellBoundMs, probeRemainingMs, cropRemainingMs, addedRemainingMs,
1220
+ // elementCap, cropsPerCell }) opts it into the readability probe, run
1221
+ // after the image probe; the observation then carries `readability`.
1222
+ async captureRoute({ url, viewport, signal, imageProbe: probeOptions = null, readabilityProbe: readabilityOptions = null } = {}) {
1223
+ return inCaptureContext({ url, viewport, signal }, async ({ page, session, awaitActive, cellLeftMs }) => {
1224
+ const collector = createNetworkCollector();
1225
+ await awaitActive(session.send("Network.enable"));
1226
+ await awaitActive(session.send("Network.setCacheDisabled", { cacheDisabled: true }));
1227
+ await awaitActive(session.send("Network.setBypassServiceWorker", { bypass: true }));
1228
+ collector.listen(session);
1229
+
1230
+ const navigationStartedAt = performance.now();
1231
+ await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS }));
1232
+ const initialFrameTree = await awaitActive(session.send("Page.getFrameTree"));
1233
+ const initialMainFrame = initialFrameTree?.frameTree?.frame;
1234
+ const initialMediaElements = await awaitActive(collectMediaElements(page));
1235
+ let networkidle;
716
1236
  try {
717
- const value = await promise;
718
- assertActive();
719
- return value;
1237
+ await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
1238
+ networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
720
1239
  } catch (error) {
721
- assertActive();
722
- throw error;
1240
+ if (!timeoutError(error)) throw error;
1241
+ networkidle = { status: "timeout", duration_ms: durationSince(navigationStartedAt) };
723
1242
  }
724
- };
725
- const triggerTimeout = () => {
726
- timedOut = true;
727
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
728
- void cleanupResources().catch(() => {});
729
- };
730
- if (signal?.aborted) triggerTimeout();
731
- else if (typeof signal?.addEventListener === "function") {
732
- signal.addEventListener("abort", triggerTimeout, { once: true });
733
- }
734
- let observation;
735
- let operationError = null;
736
- try {
737
- observation = await runWithPolishProducerDeadline(async () => {
738
- assertActive();
739
- context = await browser.newContext({
740
- viewport: { width: viewport.width, height: viewport.height },
741
- serviceWorkers: "block",
742
- });
743
- assertActive();
744
- if (authCookies.length > 0) {
745
- const origin = captureOrigin(url);
746
- // Fixed message: a URL containing credentials or query data is never echoed.
747
- if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
748
- await awaitActive(context.addCookies(authCookies.map((cookie) => ({ ...cookie, url: origin }))));
749
- }
750
- const page = await awaitActive(context.newPage());
751
- session = await context.newCDPSession(page);
752
- assertActive();
753
- const collector = createNetworkCollector();
754
- await awaitActive(session.send("Network.enable"));
755
- await awaitActive(session.send("Network.setCacheDisabled", { cacheDisabled: true }));
756
- await awaitActive(session.send("Network.setBypassServiceWorker", { bypass: true }));
757
- collector.listen(session);
1243
+ const finalMediaElements = await awaitActive(collectMediaElements(page));
1244
+ await awaitActive(drainProtocolEvents());
1245
+ const frameTree = await awaitActive(session.send("Page.getFrameTree"));
1246
+ const mainFrame = frameTree?.frameTree?.frame;
1247
+ const finalDocumentUrl = page.url();
1248
+ const documentContextChanged = typeof initialMainFrame?.id !== "string"
1249
+ || typeof initialMainFrame?.loaderId !== "string"
1250
+ || initialMainFrame.id !== mainFrame?.id
1251
+ || initialMainFrame.loaderId !== mainFrame?.loaderId;
1252
+ const mediaElements = mergeMediaElementSnapshots(
1253
+ documentContextChanged ? { observed_element_count: 0, elements: [] } : initialMediaElements,
1254
+ finalMediaElements,
1255
+ );
1256
+ const network = collector.finish({
1257
+ mainFrameId: mainFrame?.id,
1258
+ mainLoaderId: mainFrame?.loaderId,
1259
+ finalDocumentUrl,
1260
+ });
1261
+ if (documentContextChanged) {
1262
+ network.responseCollectionStatus = "failed";
1263
+ network.responses.push(captureProblemRecord("document_context_changed"));
1264
+ }
1265
+ const imageProbe = probeOptions && typeof probeOptions === "object"
1266
+ ? await awaitActive(probeImageElements({ session, mainFrame, documentContextChanged, probe: probeOptions }))
1267
+ : null;
1268
+ const readability = readabilityOptions && typeof readabilityOptions === "object"
1269
+ ? await awaitActive(probeReadabilityDocument({ session, mainFrame, documentContextChanged, probe: { ...readabilityOptions, cellLeftMs: cellLeftMs() } }))
1270
+ : null;
1271
+ return {
1272
+ finalDocumentUrl,
1273
+ responseCollectionStatus: network.responseCollectionStatus,
1274
+ networkidle,
1275
+ mediaElements,
1276
+ responses: network.responses,
1277
+ ...(imageProbe ? { imageProbe } : {}),
1278
+ ...(readability ? { readability } : {}),
1279
+ };
1280
+ });
1281
+ },
758
1282
 
1283
+ // A readability-only cell: a fresh context like captureRoute's, `goto`
1284
+ // until domcontentloaded, the network-idle wait (a timeout is recorded,
1285
+ // not a failure), then the readability probe (`probe` as captureRoute's
1286
+ // readabilityProbe). Navigation is bounded by the added run budget left
1287
+ // as well; a navigation that fails, or answers with an HTTP error, reads
1288
+ // navigation_failed, and one the budget cut, run_budget_exhausted.
1289
+ // Returns the probe's result and `networkidle`.
1290
+ async probeReadabilityRoute(route, viewport, { signal, probe = {} } = {}) {
1291
+ const clock = isProbeClock(probe.clock) ? probe.clock : realProbeClock;
1292
+ const cellStarted = clock.now();
1293
+ const addedLeft = () => probe.addedRemainingMs - Math.max(0, clock.now() - cellStarted);
1294
+ const unmeasured = (status, networkidle = null) => ({ status, capped: false, coverage_gaps: [], elements: [], crops: [], probe_ms: 0, crop_ms: 0, networkidle });
1295
+ // Every browser call of the cell is a step of a bound: the added budget
1296
+ // left on `clock` (a cell that starts with none makes no call), and the
1297
+ // cell deadline's headroom in real time. A bound the added budget ends
1298
+ // reads run_budget_exhausted; one the cell deadline's headroom ends,
1299
+ // probe_timeout. The setup calls (context, page, CDP session) have their
1300
+ // own bound, so it never cancels the page's steps.
1301
+ const token = { cancelled: false };
1302
+ const setupToken = { cancelled: false };
1303
+ let cdp = null;
1304
+ const bounded = (boundMs, hardMs, stepsToken = token) => boundedProbeSteps({ clock, session: { send: (method, params) => cdp.send(method, params) }, token: stepsToken, started: clock.now(), boundMs, hardMs });
1305
+ try {
1306
+ const setupSteps = bounded(probe.addedRemainingMs, boundedCellDeadlineMs - READABILITY_CELL_HEADROOM_MS, setupToken);
1307
+ const setup = { step: setupSteps.step, ended: () => unmeasured(setupSteps.hardEnded() ? "probe_timeout" : "run_budget_exhausted") };
1308
+ return await inCaptureContext({ url: route?.url, viewport, signal, setup }, async ({ page, session, awaitActive, cellLeftMs }) => {
1309
+ cdp = session;
1310
+ const addedAtStart = addedLeft();
1311
+ const cellAtStart = cellLeftMs();
1312
+ if (!(addedAtStart > 0)) return unmeasured("run_budget_exhausted");
1313
+ if (!(cellAtStart > 0)) return unmeasured("probe_timeout");
1314
+ const steps = bounded(addedAtStart, cellAtStart);
1315
+ const boundEnded = () => (steps.hardEnded() ? "probe_timeout" : "run_budget_exhausted");
1316
+ const navigationTimeout = Math.max(1, Math.min(NAVIGATION_TIMEOUT_MS, Math.floor(addedAtStart), Math.floor(cellAtStart)));
759
1317
  const navigationStartedAt = performance.now();
760
- await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS }));
761
- const initialFrameTree = await awaitActive(session.send("Page.getFrameTree"));
762
- const initialMainFrame = initialFrameTree?.frameTree?.frame;
763
- const initialMediaElements = await awaitActive(collectMediaElements(page));
764
- let networkidle;
765
- try {
766
- await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
767
- networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
768
- } catch (error) {
769
- if (!timeoutError(error)) throw error;
770
- networkidle = { status: "timeout", duration_ms: durationSince(navigationStartedAt) };
1318
+ const navigated = await awaitActive(steps.step(() => page.goto(route.url, { waitUntil: "domcontentloaded", timeout: navigationTimeout })));
1319
+ if (navigated === BOUND_ENDED) return unmeasured(boundEnded());
1320
+ if (navigated.error) {
1321
+ if (navigated.error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw navigated.error;
1322
+ const cutByAdded = navigationTimeout < NAVIGATION_TIMEOUT_MS && navigationTimeout === Math.max(1, Math.floor(addedAtStart));
1323
+ return unmeasured(timeoutError(navigated.error) && cutByAdded ? "run_budget_exhausted" : "navigation_failed");
771
1324
  }
772
- const finalMediaElements = await awaitActive(collectMediaElements(page));
773
- await awaitActive(drainProtocolEvents());
774
- const frameTree = await awaitActive(session.send("Page.getFrameTree"));
775
- const mainFrame = frameTree?.frameTree?.frame;
776
- const finalDocumentUrl = page.url();
1325
+ if (!navigated.value || navigated.value.status() >= 400) return unmeasured("navigation_failed");
1326
+ const initialTree = await awaitActive(steps.step(() => steps.send("Page.getFrameTree")));
1327
+ if (initialTree === BOUND_ENDED) return unmeasured(boundEnded());
1328
+ if (initialTree.error) return unmeasured("document_changed");
1329
+ const initialMainFrame = initialTree.value?.frameTree?.frame;
1330
+ const idleTimeout = Math.max(1, Math.min(NETWORKIDLE_TIMEOUT_MS, Math.floor(addedLeft()), Math.floor(cellLeftMs())));
1331
+ const idle = await awaitActive(steps.step(() => page.waitForLoadState("networkidle", { timeout: idleTimeout })));
1332
+ if (idle === BOUND_ENDED) return unmeasured(boundEnded());
1333
+ if (idle.error && !timeoutError(idle.error)) throw idle.error;
1334
+ const networkidle = { status: idle.error ? "timeout" : "settled", duration_ms: durationSince(navigationStartedAt) };
1335
+ const finalTree = await awaitActive(steps.step(() => steps.send("Page.getFrameTree")));
1336
+ if (finalTree === BOUND_ENDED) return unmeasured(boundEnded(), networkidle);
1337
+ if (finalTree.error) return unmeasured("document_changed", networkidle);
1338
+ const mainFrame = finalTree.value?.frameTree?.frame;
777
1339
  const documentContextChanged = typeof initialMainFrame?.id !== "string"
778
1340
  || typeof initialMainFrame?.loaderId !== "string"
779
1341
  || initialMainFrame.id !== mainFrame?.id
780
1342
  || initialMainFrame.loaderId !== mainFrame?.loaderId;
781
- const mediaElements = mergeMediaElementSnapshots(
782
- documentContextChanged ? { observed_element_count: 0, elements: [] } : initialMediaElements,
783
- finalMediaElements,
784
- );
785
- const network = collector.finish({
786
- mainFrameId: mainFrame?.id,
787
- mainLoaderId: mainFrame?.loaderId,
788
- finalDocumentUrl,
789
- });
790
- if (documentContextChanged) {
791
- network.responseCollectionStatus = "failed";
792
- network.responses.push(captureProblemRecord("document_context_changed"));
793
- }
794
- return {
795
- finalDocumentUrl,
796
- responseCollectionStatus: network.responseCollectionStatus,
797
- networkidle,
798
- mediaElements,
799
- responses: network.responses,
800
- };
801
- }, {
802
- timeoutMs: boundedCellDeadlineMs,
803
- onTimeout: triggerTimeout,
804
- signal,
1343
+ const readability = await awaitActive(probeReadabilityDocument({
1344
+ session,
1345
+ mainFrame,
1346
+ documentContextChanged,
1347
+ probe: { ...probe, addedRemainingMs: addedLeft(), cellLeftMs: cellLeftMs() },
1348
+ }));
1349
+ return { ...readability, networkidle };
805
1350
  });
806
- } catch (error) {
807
- operationError = error;
808
- if (error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
809
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
810
- }
811
1351
  } finally {
812
- if (typeof signal?.removeEventListener === "function") {
813
- signal.removeEventListener("abort", triggerTimeout);
814
- }
815
- }
816
- let cleanupError = null;
817
- try {
818
- await runWithPolishProducerDeadline(cleanupResources, { timeoutMs: boundedCleanupDeadlineMs });
819
- } catch (error) {
820
- cleanupError = error;
821
- }
822
- if (cleanupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
823
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
824
- throw cleanupError;
825
- }
826
- if (operationError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
827
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
828
- throw operationError;
829
- }
830
- if (cleanupError) {
831
- poisonCode = POLISH_PRODUCER_CLEANUP_ERROR_CODE;
832
- throw polishProducerCleanupError();
1352
+ token.cancelled = true;
1353
+ setupToken.cancelled = true;
833
1354
  }
834
- if (operationError) throw operationError;
835
- return observation;
836
1355
  },
837
1356
 
838
1357
  async close() {