@nextcommerce/campaigns-os 1.52.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. package/src/tooling-setup.mjs +1 -1
@@ -18,6 +18,11 @@ import {
18
18
  } from "./polish-capture.mjs";
19
19
  import { launchPackageChromium, PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
20
20
  import { isProbeClock } from "./polish-media-weight.mjs";
21
+ import {
22
+ readabilityCropTargets,
23
+ readabilityObservationOk,
24
+ readabilityProbeSource,
25
+ } from "./polish-readability.mjs";
21
26
  import {
22
27
  boundedPolishDeadline,
23
28
  POLISH_BROWSER_CELL_DEADLINE_MS,
@@ -587,22 +592,28 @@ function readImageElements(limits, ...closedRoots) {
587
592
 
588
593
  // The backend node ids of the main document's closed shadow roots, from a
589
594
  // 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) {
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 } = {}) {
593
600
  const found = [];
594
- const pending = [root];
601
+ const pending = [[root, null]];
595
602
  while (pending.length) {
596
- const node = pending.pop();
603
+ const [node, owner] = pending.pop();
597
604
  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);
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]);
601
609
  }
602
- return found;
610
+ return [...new Set(found)];
603
611
  }
604
612
 
605
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;
606
617
  const realProbeClock = Object.freeze({
607
618
  now: () => performance.now(),
608
619
  sleep: (ms) => new Promise((resolve) => {
@@ -649,76 +660,248 @@ async function probeImageElements(options) {
649
660
  }
650
661
  }
651
662
 
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);
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 }) {
662
677
  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.
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";
670
686
  const cancelled = () => {
671
- if (!token.cancelled && clock.now() >= boundEnd) token.cancelled = true;
687
+ if (performance.now() >= hardEnd) end("hard");
688
+ else if (clock.now() >= boundEnd) end("clock");
672
689
  return token.cancelled;
673
690
  };
674
691
  const send = (method, params) => (cancelled()
675
- ? Promise.reject(new Error("The image probe was cancelled."))
692
+ ? Promise.reject(new Error("The probe was cancelled."))
676
693
  : 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.
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);
683
698
  const step = async (start) => {
684
699
  if (cancelled()) return BOUND_ENDED;
685
700
  const settled = await Promise.race([Promise.resolve().then(start).then((value) => ({ value }), (error) => ({ error })), deadline]);
686
701
  return cancelled() ? BOUND_ENDED : settled;
687
702
  };
703
+ return { cancelled, hardEnded, send, step };
704
+ }
688
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;
689
718
  const world = await step(() => send("Page.createIsolatedWorld", {
690
719
  frameId: mainFrame.id,
691
- worldName: "campaigns-os-polish-image-probe",
720
+ worldName,
692
721
  grantUniveralAccess: false,
693
722
  }));
694
- if (world === BOUND_ENDED) return outcome(boundEnded);
723
+ if (world === BOUND_ENDED) return BOUND_ENDED;
695
724
  const contextId = world.value?.executionContextId;
696
- if (world.error || !Number.isInteger(contextId)) return outcome("document_context_changed");
725
+ if (world.error || !Number.isInteger(contextId)) return null;
697
726
  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)
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 })
701
730
  .map((backendNodeId) => send("DOM.resolveNode", { backendNodeId, executionContextId: contextId }))));
702
- if (roots === BOUND_ENDED) return outcome(boundEnded);
731
+ if (roots === BOUND_ENDED) return BOUND_ENDED;
703
732
  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");
733
+ if (!rootIds || !rootIds.every((objectId) => typeof objectId === "string")) return null;
705
734
  const evaluated = await step(() => send("Runtime.callFunctionOn", {
706
- functionDeclaration: readImageElements.toString(),
735
+ functionDeclaration,
707
736
  executionContextId: contextId,
708
- arguments: [{ value: limits }, ...rootIds.map((objectId) => ({ objectId }))],
737
+ arguments: [...leadingArgs, ...rootIds.map((objectId) => ({ objectId }))],
709
738
  returnByValue: true,
710
739
  }));
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");
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;
718
752
  const frame = reread.value?.frameTree?.frame;
719
- if (frame?.id !== mainFrame.id || frame?.loaderId !== mainFrame.loaderId) return outcome("document_context_changed");
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");
720
784
  if (read.observed_count > probe.imageCap) return outcome("image_cap_reached");
721
- return cancelled() ? outcome(boundEnded) : outcome("complete");
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
+ };
722
905
  }
723
906
 
724
907
  function preferredResolvedValue(finalValue, initialValue) {
@@ -885,171 +1068,290 @@ export async function createPolishBrowserAdapter({
885
1068
  let closed = false;
886
1069
  let poisonCode = null;
887
1070
  let closePromise = null;
888
- return {
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 } = {}) {
893
- if (closed) throw new Error("Campaigns OS polish capture browser adapter is already closed.");
894
- if (poisonCode === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) throw polishProducerTimeoutError();
895
- if (poisonCode === POLISH_PRODUCER_CLEANUP_ERROR_CODE) throw polishProducerCleanupError();
896
- if (typeof url !== "string" || url.length > MAX_POLISH_CAPTURE_URL_LENGTH) {
897
- 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(() => {});
898
1102
  }
899
- if (!Number.isInteger(viewport?.width) || viewport.width <= 0
900
- || !Number.isInteger(viewport?.height) || viewport.height <= 0) {
901
- 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());
902
1105
  }
903
- let context;
904
- let session;
905
- let timedOut = false;
906
- let detachPromise = null;
907
- let contextClosePromise = null;
908
- const cleanupResources = () => {
909
- if (typeof session?.detach === "function" && !detachPromise) {
910
- detachPromise = Promise.resolve().then(() => session.detach());
911
- // Context closure is authoritative. CDP detach commonly rejects when
912
- // that same close wins the race, so observe but never await or expose it.
913
- void detachPromise.catch(() => {});
914
- }
915
- if (typeof context?.close === "function" && !contextClosePromise) {
916
- 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 }))));
917
1173
  }
918
- if (!context) return Promise.resolve();
919
- if (!contextClosePromise) return Promise.reject(polishProducerCleanupError());
920
- return contextClosePromise.catch(() => { throw polishProducerCleanupError(); });
921
- };
922
- const assertActive = () => {
923
- 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) {
924
1187
  poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
925
- void cleanupResources().catch(() => {});
926
- throw polishProducerTimeoutError();
927
- };
928
- 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;
929
1236
  try {
930
- const value = await promise;
931
- assertActive();
932
- return value;
1237
+ await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
1238
+ networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
933
1239
  } catch (error) {
934
- assertActive();
935
- throw error;
1240
+ if (!timeoutError(error)) throw error;
1241
+ networkidle = { status: "timeout", duration_ms: durationSince(navigationStartedAt) };
936
1242
  }
937
- };
938
- const triggerTimeout = () => {
939
- timedOut = true;
940
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
941
- void cleanupResources().catch(() => {});
942
- };
943
- if (signal?.aborted) triggerTimeout();
944
- else if (typeof signal?.addEventListener === "function") {
945
- signal.addEventListener("abort", triggerTimeout, { once: true });
946
- }
947
- let observation;
948
- let operationError = null;
949
- try {
950
- observation = await runWithPolishProducerDeadline(async () => {
951
- assertActive();
952
- context = await browser.newContext({
953
- viewport: { width: viewport.width, height: viewport.height },
954
- serviceWorkers: "block",
955
- });
956
- assertActive();
957
- if (authCookies.length > 0) {
958
- const origin = captureOrigin(url);
959
- // Fixed message: a URL containing credentials or query data is never echoed.
960
- if (origin === null) throw new Error("Campaigns OS polish capture requires an HTTP(S) capture URL before applying --auth-cookie.");
961
- await awaitActive(context.addCookies(authCookies.map((cookie) => ({ ...cookie, url: origin }))));
962
- }
963
- const page = await awaitActive(context.newPage());
964
- session = await context.newCDPSession(page);
965
- assertActive();
966
- const collector = createNetworkCollector();
967
- await awaitActive(session.send("Network.enable"));
968
- await awaitActive(session.send("Network.setCacheDisabled", { cacheDisabled: true }));
969
- await awaitActive(session.send("Network.setBypassServiceWorker", { bypass: true }));
970
- 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
+ },
971
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)));
972
1317
  const navigationStartedAt = performance.now();
973
- await awaitActive(page.goto(url, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS }));
974
- const initialFrameTree = await awaitActive(session.send("Page.getFrameTree"));
975
- const initialMainFrame = initialFrameTree?.frameTree?.frame;
976
- const initialMediaElements = await awaitActive(collectMediaElements(page));
977
- let networkidle;
978
- try {
979
- await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
980
- networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
981
- } catch (error) {
982
- if (!timeoutError(error)) throw error;
983
- 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");
984
1324
  }
985
- const finalMediaElements = await awaitActive(collectMediaElements(page));
986
- await awaitActive(drainProtocolEvents());
987
- const frameTree = await awaitActive(session.send("Page.getFrameTree"));
988
- const mainFrame = frameTree?.frameTree?.frame;
989
- 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;
990
1339
  const documentContextChanged = typeof initialMainFrame?.id !== "string"
991
1340
  || typeof initialMainFrame?.loaderId !== "string"
992
1341
  || initialMainFrame.id !== mainFrame?.id
993
1342
  || initialMainFrame.loaderId !== mainFrame?.loaderId;
994
- const mediaElements = mergeMediaElementSnapshots(
995
- documentContextChanged ? { observed_element_count: 0, elements: [] } : initialMediaElements,
996
- finalMediaElements,
997
- );
998
- const network = collector.finish({
999
- mainFrameId: mainFrame?.id,
1000
- mainLoaderId: mainFrame?.loaderId,
1001
- finalDocumentUrl,
1002
- });
1003
- if (documentContextChanged) {
1004
- network.responseCollectionStatus = "failed";
1005
- network.responses.push(captureProblemRecord("document_context_changed"));
1006
- }
1007
- const imageProbe = probeOptions && typeof probeOptions === "object"
1008
- ? await awaitActive(probeImageElements({ session, mainFrame, documentContextChanged, probe: probeOptions }))
1009
- : null;
1010
- return {
1011
- finalDocumentUrl,
1012
- responseCollectionStatus: network.responseCollectionStatus,
1013
- networkidle,
1014
- mediaElements,
1015
- responses: network.responses,
1016
- ...(imageProbe ? { imageProbe } : {}),
1017
- };
1018
- }, {
1019
- timeoutMs: boundedCellDeadlineMs,
1020
- onTimeout: triggerTimeout,
1021
- 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 };
1022
1350
  });
1023
- } catch (error) {
1024
- operationError = error;
1025
- if (error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
1026
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1027
- }
1028
1351
  } finally {
1029
- if (typeof signal?.removeEventListener === "function") {
1030
- signal.removeEventListener("abort", triggerTimeout);
1031
- }
1032
- }
1033
- let cleanupError = null;
1034
- try {
1035
- await runWithPolishProducerDeadline(cleanupResources, { timeoutMs: boundedCleanupDeadlineMs });
1036
- } catch (error) {
1037
- cleanupError = error;
1038
- }
1039
- if (cleanupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
1040
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1041
- throw cleanupError;
1042
- }
1043
- if (operationError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE) {
1044
- poisonCode = POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
1045
- throw operationError;
1046
- }
1047
- if (cleanupError) {
1048
- poisonCode = POLISH_PRODUCER_CLEANUP_ERROR_CODE;
1049
- throw polishProducerCleanupError();
1352
+ token.cancelled = true;
1353
+ setupToken.cancelled = true;
1050
1354
  }
1051
- if (operationError) throw operationError;
1052
- return observation;
1053
1355
  },
1054
1356
 
1055
1357
  async close() {