harnery 0.37.0 → 0.38.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 (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -11,8 +11,13 @@ import {
11
11
  type Cookie as PWCookie,
12
12
  type Request,
13
13
  } from "playwright";
14
- import type { CookieJar, Cookie as JarCookie } from "../cookies/index.ts";
14
+ import {
15
+ type CookieJar,
16
+ CookieStoreParseError,
17
+ type Cookie as JarCookie,
18
+ } from "../cookies/index.ts";
15
19
  import { type AssertResult, type AssertSpec, buildAssertCheck } from "./asserts.js";
20
+ import { stitchPngRows } from "./capture-fidelity.js";
16
21
  import {
17
22
  buildClearContentAnnotationsScript,
18
23
  buildContentAnnotateScript,
@@ -36,6 +41,7 @@ import {
36
41
  type OverflowResult,
37
42
  type WidthResult,
38
43
  } from "./layout.js";
44
+ import { isBenignNextRscAbort } from "./request-diagnostics.js";
39
45
  import {
40
46
  buildClearRuntsAnnotationsScript,
41
47
  buildRuntsAnnotateScript,
@@ -86,6 +92,12 @@ export interface BrowserOptions {
86
92
  jar?: CookieJar | null;
87
93
  /** Viewport. Default 1280x800. */
88
94
  viewport?: { width: number; height: number };
95
+ /**
96
+ * Device scale factor (DPR) for the context. Unset keeps Playwright's
97
+ * default of 1, so screenshots are CSS-pixel sized. A higher value renders
98
+ * every screenshot at that multiple (2 doubles both dimensions).
99
+ */
100
+ deviceScaleFactor?: number;
89
101
  /**
90
102
  * Emulated `prefers-color-scheme` for the context. Unset keeps Playwright's
91
103
  * default (light), byte-identical to prior behavior.
@@ -313,12 +325,17 @@ export class Browser {
313
325
  * the whole sequence — not just the launch — can be retried as a unit.
314
326
  */
315
327
  private async openOnce(): Promise<void> {
328
+ const jarCookies = this.opts.jar?.list() ?? [];
329
+
316
330
  // Hold a launch slot only across the spawn itself, not the browser's life.
317
331
  await acquireLaunchSlot();
318
332
  try {
319
333
  this.context = await chromium.launchPersistentContext(this.profileDir, {
320
334
  headless: !this.opts.headed,
321
335
  viewport: this.opts.viewport ?? { width: 1280, height: 800 },
336
+ ...(this.opts.deviceScaleFactor !== undefined
337
+ ? { deviceScaleFactor: this.opts.deviceScaleFactor }
338
+ : {}),
322
339
  ...(this.opts.colorScheme ? { colorScheme: this.opts.colorScheme } : {}),
323
340
  ...(this.opts.launchTimeout !== undefined ? { timeout: this.opts.launchTimeout } : {}),
324
341
  ...(this.opts.launchArgs && this.opts.launchArgs.length > 0
@@ -334,11 +351,8 @@ export class Browser {
334
351
  }
335
352
  this.context.setDefaultNavigationTimeout(this.opts.navigationTimeout ?? 30_000);
336
353
 
337
- if (this.opts.jar) {
338
- const jarCookies = this.opts.jar.list();
339
- if (jarCookies.length > 0) {
340
- await this.context.addCookies(jarCookies.map(toPWCookie));
341
- }
354
+ if (jarCookies.length > 0) {
355
+ await this.context.addCookies(jarCookies.map(toPWCookie));
342
356
  }
343
357
 
344
358
  // Caller-injected extraHeaders callback (e.g., for Cloudflare-bypass
@@ -402,6 +416,11 @@ export class Browser {
402
416
  this.consoleEvents = [];
403
417
  this.pageErrors = [];
404
418
  this.failedRequests = [];
419
+ if (err instanceof CookieStoreParseError) {
420
+ throw new Error(`Failed to open browser during cookie-store load: ${err.message}`, {
421
+ cause: err,
422
+ });
423
+ }
405
424
  if (attempt < maxAttempts) {
406
425
  await new Promise((resolve) => setTimeout(resolve, attempt * 250));
407
426
  }
@@ -431,11 +450,15 @@ export class Browser {
431
450
  this.pageErrors.push({ message: err.message, stack: err.stack });
432
451
  });
433
452
  page.on("requestfailed", (req: Request) => {
434
- this.failedRequests.push({
453
+ const failedRequest = {
435
454
  url: req.url(),
436
455
  method: req.method(),
437
456
  failure: req.failure()?.errorText ?? "unknown",
438
457
  resourceType: req.resourceType(),
458
+ };
459
+ if (isBenignNextRscAbort(failedRequest)) return;
460
+ this.failedRequests.push({
461
+ ...failedRequest,
439
462
  status: null,
440
463
  kind: "network",
441
464
  });
@@ -613,6 +636,181 @@ export class Browser {
613
636
  return buf.toString("base64");
614
637
  }
615
638
 
639
+ /**
640
+ * PNG of a document-space rect captured the way a reader sees it: scroll so
641
+ * the rect's top sits at the top of the viewport, wait for the scroll to
642
+ * settle (instant scroll, then rAF frames until `scrollY` stops moving),
643
+ * and clip the viewport. Restores the scroll position afterwards.
644
+ *
645
+ * Bands are taller than the viewport. Rather than resizing the viewport to
646
+ * the band height (which reflows `vh`-sized layout, moves sticky and fixed
647
+ * elements, and so stops matching what the user sees), the rect is captured
648
+ * in viewport-height pieces at the real viewport and the pieces are
649
+ * stitched with pngjs. The final piece of a multi-piece rect is anchored to
650
+ * the viewport bottom so a fixed header, which sits at the viewport top, is
651
+ * not repeated in the middle of the band. No viewport change is made.
652
+ *
653
+ * Pixels come back at the page's device scale factor, so a rect at DPR 2
654
+ * yields an image twice the rect's CSS size, the same as a full-page
655
+ * screenshot cropped in pixel space would.
656
+ *
657
+ * Parity with the full-page screenshot: a full-page capture renders the
658
+ * whole document as one viewport, so a fixed header or a stuck sticky bar
659
+ * appears once, at its document position. Scrolled, the same element would
660
+ * ride along at the top of every piece and differ from the full-page crop
661
+ * on every band but the first. So while a piece is captured at a non-zero
662
+ * scroll, every `position: fixed`/`sticky` element that has left its
663
+ * scroll-0 document position is given `visibility: hidden` (layout kept,
664
+ * inline style restored afterwards). The result is the region as a
665
+ * full-page screenshot would show it, without the full-page raster.
666
+ */
667
+ async captureRegionByScroll(rect: {
668
+ x: number;
669
+ y: number;
670
+ width: number;
671
+ height: number;
672
+ }): Promise<Buffer> {
673
+ const page = this.currentPage;
674
+ if (!(rect.width > 0) || !(rect.height > 0)) {
675
+ throw new Error(
676
+ `captureRegionByScroll: rect must have a positive size (got ${rect.width}×${rect.height})`,
677
+ );
678
+ }
679
+ const settle = (top: number) =>
680
+ page.evaluate(async (target: number) => {
681
+ window.scrollTo({ left: window.scrollX, top: target, behavior: "instant" });
682
+ const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
683
+ let last = Number.NaN;
684
+ for (let i = 0; i < 8; i++) {
685
+ await frame();
686
+ const now = window.scrollY;
687
+ if (now === last) break;
688
+ last = now;
689
+ }
690
+ return {
691
+ scrollX: window.scrollX,
692
+ scrollY: window.scrollY,
693
+ innerWidth: window.innerWidth,
694
+ innerHeight: window.innerHeight,
695
+ };
696
+ }, top);
697
+ const original = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
698
+ const pieces: Buffer[] = [];
699
+ // Viewport-pinned candidates, measured at scroll 0 so each later capture
700
+ // can tell "still where the full page shows it" from "riding along".
701
+ await settle(0);
702
+ // Hiding uses opacity, not visibility: `visibility: hidden` on the pinned
703
+ // element is undone by any descendant that sets `visibility: visible`
704
+ // (a revealed brand label inside a sticky bar did exactly that), while
705
+ // opacity is a group effect no descendant can override. Transitions are
706
+ // switched off inline so the capture never lands mid-fade.
707
+ const pinned = await page.evaluateHandle(() => {
708
+ const out: Array<{
709
+ el: HTMLElement;
710
+ top: number;
711
+ priorOpacity: string;
712
+ priorOpacityPriority: string;
713
+ priorTransition: string;
714
+ priorTransitionPriority: string;
715
+ }> = [];
716
+ const all = document.querySelectorAll<HTMLElement>("body *");
717
+ const limit = Math.min(all.length, 20_000);
718
+ for (let i = 0; i < limit; i++) {
719
+ const el = all[i] as HTMLElement;
720
+ const position = getComputedStyle(el).position;
721
+ if (position !== "fixed" && position !== "sticky") continue;
722
+ out.push({
723
+ el,
724
+ top: el.getBoundingClientRect().top + window.scrollY,
725
+ priorOpacity: el.style.getPropertyValue("opacity"),
726
+ priorOpacityPriority: el.style.getPropertyPriority("opacity"),
727
+ priorTransition: el.style.getPropertyValue("transition"),
728
+ priorTransitionPriority: el.style.getPropertyPriority("transition"),
729
+ });
730
+ }
731
+ return out;
732
+ });
733
+ const syncPinned = (mode: "hide-moved" | "restore") =>
734
+ page.evaluate(
735
+ async ([entries, action]) => {
736
+ for (const entry of entries) {
737
+ const restore = () => {
738
+ entry.el.style.setProperty("opacity", entry.priorOpacity, entry.priorOpacityPriority);
739
+ entry.el.style.setProperty(
740
+ "transition",
741
+ entry.priorTransition,
742
+ entry.priorTransitionPriority,
743
+ );
744
+ };
745
+ if (action === "restore") {
746
+ restore();
747
+ continue;
748
+ }
749
+ const now = entry.el.getBoundingClientRect().top + window.scrollY;
750
+ if (Math.abs(now - entry.top) > 1) {
751
+ entry.el.style.setProperty("transition", "none", "important");
752
+ entry.el.style.setProperty("opacity", "0", "important");
753
+ } else {
754
+ restore();
755
+ }
756
+ }
757
+ // Let the style change paint before the screenshot is taken.
758
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
759
+ },
760
+ [pinned, mode] as const,
761
+ );
762
+ try {
763
+ const bottom = rect.y + rect.height;
764
+ let cursor = rect.y;
765
+ let first = true;
766
+ while (cursor < bottom) {
767
+ const remaining = bottom - cursor;
768
+ // Top-align the piece; a final partial piece is then re-anchored to
769
+ // the viewport bottom once the viewport height is known.
770
+ let view = await settle(cursor);
771
+ if (!first && remaining < view.innerHeight) {
772
+ view = await settle(Math.max(0, bottom - view.innerHeight));
773
+ }
774
+ await syncPinned("hide-moved");
775
+ const offset = cursor - view.scrollY;
776
+ if (offset < 0 || offset >= view.innerHeight) {
777
+ throw new Error(
778
+ `captureRegionByScroll: document y ${cursor} is not reachable in the viewport ` +
779
+ `(scrollY ${view.scrollY}, innerHeight ${view.innerHeight})`,
780
+ );
781
+ }
782
+ const pieceHeight = Math.min(remaining, view.innerHeight - offset);
783
+ const clipX = Math.max(0, rect.x - view.scrollX);
784
+ const clipWidth = Math.min(rect.width, view.innerWidth - clipX);
785
+ if (!(clipWidth > 0)) {
786
+ throw new Error(
787
+ `captureRegionByScroll: document x ${rect.x} is outside the viewport (innerWidth ${view.innerWidth})`,
788
+ );
789
+ }
790
+ pieces.push(
791
+ await page.screenshot({
792
+ type: "png",
793
+ clip: { x: clipX, y: offset, width: clipWidth, height: pieceHeight },
794
+ }),
795
+ );
796
+ cursor += pieceHeight;
797
+ first = false;
798
+ }
799
+ } finally {
800
+ try {
801
+ await syncPinned("restore");
802
+ } finally {
803
+ await pinned.dispose();
804
+ await page.evaluate(
805
+ ({ x, y }: { x: number; y: number }) =>
806
+ window.scrollTo({ left: x, top: y, behavior: "instant" }),
807
+ original,
808
+ );
809
+ }
810
+ }
811
+ return stitchPngRows(pieces);
812
+ }
813
+
616
814
  /** Document-space rects + labels for each element matching `selector` (semantic tiling). */
617
815
  async elementTiles(
618
816
  selector: string,
@@ -1600,9 +1798,7 @@ export class Browser {
1600
1798
  if (this.opts.jar) {
1601
1799
  try {
1602
1800
  const live = await this.context.cookies();
1603
- for (const c of live) {
1604
- this.opts.jar.set(toJarCookie(c));
1605
- }
1801
+ this.opts.jar.merge(live.map(toJarCookie));
1606
1802
  } catch {
1607
1803
  // Cookie persist is best-effort; never block close on it.
1608
1804
  }
@@ -18,7 +18,12 @@
18
18
  // orchestration live here; the model call lives in the host.
19
19
 
20
20
  import { PNG } from "pngjs";
21
- import { bandOversizedRect, snappedBandRects, type VisualAtom } from "./tiling.js";
21
+ import {
22
+ bandOversizedRect,
23
+ bandsCoveringRects,
24
+ snappedBandRects,
25
+ type VisualAtom,
26
+ } from "./tiling.js";
22
27
 
23
28
  export interface CritiqueTile {
24
29
  index: number;
@@ -42,10 +47,27 @@ export interface CritiqueFinding {
42
47
  description: string;
43
48
  }
44
49
 
50
+ /**
51
+ * How much of the page the tiles actually cover. The tiler keeps at most
52
+ * `maxTiles` bands and drops the rest, so on a tall page `capped` is true and
53
+ * `reviewed_height_px` stops short of `page_height_px`. Callers that turn a
54
+ * critique into a verdict must read this: a pass over the first 24 bands is
55
+ * not a pass over the page.
56
+ */
57
+ export interface CritiqueCoverage {
58
+ page_height_px: number;
59
+ reviewed_height_px: number;
60
+ bands_total: number;
61
+ bands_reviewed: number;
62
+ capped: boolean;
63
+ }
64
+
45
65
  export interface CritiqueResult {
46
66
  rule: "critique";
47
67
  /** Number of tiles the page was cut into. */
48
68
  tiles: number;
69
+ /** Tile coverage of the page; absent only for results built without tiles. */
70
+ coverage?: CritiqueCoverage;
49
71
  /** Whether a provider was available. False → outcome "skipped". */
50
72
  provider: boolean;
51
73
  findings: CritiqueFinding[];
@@ -137,6 +159,15 @@ export function bandRects(
137
159
  * the provider would downscale. Atom coordinates must be in the same pixel
138
160
  * space as the screenshot (identical at deviceScaleFactor 1; scale them if
139
161
  * the capture DPR differs).
162
+ *
163
+ * `hitRects` (document-space rectangles a deterministic gate already flagged)
164
+ * add bands past the `maxTiles` cap: every full-page band that intersects a
165
+ * hit rect and was dropped by the cap is cut as well, labelled `hit band N`
166
+ * (N is the band's 1-based position in the full band list, and the tile's
167
+ * `index` is that position minus one), and appended after the kept bands.
168
+ * `coverage` stays honest about the kept prefix; `hitBands` counts the
169
+ * extras. Ignored for selector tiling (`elementRects`), which has no cap to
170
+ * look past in that sense.
140
171
  */
141
172
  export function tilesFromFullPage(
142
173
  fullPageBuffer: Buffer,
@@ -146,8 +177,11 @@ export function tilesFromFullPage(
146
177
  overlap?: number;
147
178
  maxTiles: number;
148
179
  atoms?: VisualAtom[];
180
+ hitRects?: Array<{ x: number; y: number; width: number; height: number }>;
181
+ /** Cap on hit bands appended past `maxTiles` (default 50). */
182
+ maxHitBands?: number;
149
183
  },
150
- ): CritiqueTile[] {
184
+ ): { tiles: CritiqueTile[]; coverage: CritiqueCoverage; hitBands: number } {
151
185
  const png = PNG.sync.read(fullPageBuffer);
152
186
  const snapOpts = {
153
187
  bandHeight: opts.bandHeight ?? 1400,
@@ -185,8 +219,7 @@ export function tilesFromFullPage(
185
219
  height: b.height,
186
220
  }));
187
221
  }
188
- const tiles: CritiqueTile[] = [];
189
- for (const rect of rects.slice(0, opts.maxTiles)) {
222
+ const cutTile = (rect: (typeof rects)[number]): CritiqueTile => {
190
223
  const sx = Math.max(0, Math.min(Math.round(rect.x), png.width - 1));
191
224
  const sy = Math.max(0, Math.min(Math.round(rect.y), png.height - 1));
192
225
  const w = Math.max(1, Math.min(Math.round(rect.width), png.width - sx));
@@ -199,7 +232,7 @@ export function tilesFromFullPage(
199
232
  const dstStart = row * w * 4;
200
233
  png.data.copy(dst.data, dstStart, srcStart, srcStart + w * 4);
201
234
  }
202
- tiles.push({
235
+ return {
203
236
  index: rect.index,
204
237
  label: rect.label,
205
238
  scrollY: sy,
@@ -207,9 +240,31 @@ export function tilesFromFullPage(
207
240
  width: w,
208
241
  height: h,
209
242
  pngBase64: PNG.sync.write(dst).toString("base64"),
210
- });
243
+ };
244
+ };
245
+ const kept = rects.slice(0, opts.maxTiles);
246
+ const tiles: CritiqueTile[] = kept.map(cutTile);
247
+ let hitBands = 0;
248
+ const banding = !(opts.elementRects && opts.elementRects.length > 0);
249
+ if (banding && opts.hitRects && opts.hitRects.length > 0) {
250
+ for (const index of bandsCoveringRects(rects, kept.length, opts.hitRects, opts.maxHitBands)) {
251
+ const band = rects[index];
252
+ if (!band) continue;
253
+ tiles.push(cutTile({ ...band, label: `hit band ${index + 1}` }));
254
+ hitBands++;
255
+ }
211
256
  }
212
- return tiles;
257
+ const coverage: CritiqueCoverage = {
258
+ page_height_px: png.height,
259
+ reviewed_height_px: kept.reduce(
260
+ (max, rect) => Math.max(max, Math.min(png.height, Math.round(rect.y + rect.height))),
261
+ 0,
262
+ ),
263
+ bands_total: rects.length,
264
+ bands_reviewed: kept.length,
265
+ capped: rects.length > kept.length,
266
+ };
267
+ return { tiles, coverage, hitBands };
213
268
  }
214
269
 
215
270
  /**
@@ -39,6 +39,7 @@ export type {
39
39
  } from "./content-checks.js";
40
40
  export {
41
41
  bandRects,
42
+ type CritiqueCoverage,
42
43
  type CritiqueFinding,
43
44
  type CritiqueProvider,
44
45
  type CritiqueResult,
@@ -85,6 +86,39 @@ export {
85
86
  serializeNetscapeCookies,
86
87
  writeNetscapeCookieFile,
87
88
  } from "./netscape-cookies.js";
89
+ export {
90
+ type JudgedContext,
91
+ type JudgePageReviewPackOptions,
92
+ type JudgePageReviewPackResult,
93
+ judgePageReviewPack,
94
+ toCritiqueRecords,
95
+ } from "./page-review-judge.js";
96
+ export {
97
+ buildInspectionPlan,
98
+ type FinalizePageReviewPackInput,
99
+ finalizePageReviewPack,
100
+ findingsSchemaDocument,
101
+ listPackContexts,
102
+ PAGE_REVIEW_FINDINGS_SCHEMA,
103
+ PAGE_REVIEW_PACK_DIRNAME,
104
+ PAGE_REVIEW_PACK_SCHEMA,
105
+ type PageReviewContextCapture,
106
+ type PageReviewContextRecord,
107
+ type PageReviewCritiqueRecord,
108
+ type PageReviewGateRecord,
109
+ type PageReviewInspectionPlan,
110
+ type PageReviewPackManifest,
111
+ type PageReviewTileRecord,
112
+ packPaths,
113
+ readPackContext,
114
+ readPackDom,
115
+ readPackFullPage,
116
+ readPackManifest,
117
+ readPackSignature,
118
+ readPackTiles,
119
+ renderReviewMarkdown,
120
+ writePackContext,
121
+ } from "./page-review-pack.js";
88
122
  export {
89
123
  type BrowserProxy,
90
124
  type BrowserProxyGate,
@@ -129,11 +163,13 @@ export {
129
163
  type QaRunCommandOutcome,
130
164
  type QaRunContext,
131
165
  type QaRunCritiqueOutcome,
166
+ type QaRunCritiquePool,
132
167
  type QaRunInteraction,
133
168
  type QaRunJob,
134
169
  type QaRunJobValidation,
135
170
  type QaRunPolicy,
136
171
  type QaRunResult,
172
+ type QaRunReviewPack,
137
173
  type QaRunVerdict,
138
174
  validateQaRunJob,
139
175
  } from "./qa-run-contracts.js";