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.
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +7 -2
- package/dist/commands/admission.d.ts +21 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +565 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +61 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +48 -2
- package/dist/commands/browse-ai.d.ts +2 -2
- package/dist/commands/browse-ai.d.ts.map +1 -1
- package/dist/commands/browse-ai.js +6 -4
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +349 -21
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/qa-record.d.ts +144 -0
- package/dist/commands/qa-record.d.ts.map +1 -0
- package/dist/commands/qa-record.js +0 -0
- package/dist/commands/qa-run.d.ts +7 -3
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +268 -13
- package/dist/commands/qa-status.d.ts +71 -0
- package/dist/commands/qa-status.d.ts.map +1 -0
- package/dist/commands/qa-status.js +490 -0
- package/dist/commands/qa-verify.d.ts +40 -0
- package/dist/commands/qa-verify.d.ts.map +1 -0
- package/dist/commands/qa-verify.js +180 -0
- package/dist/commands/review-pack.d.ts +4 -0
- package/dist/commands/review-pack.d.ts.map +1 -0
- package/dist/commands/review-pack.js +1001 -0
- package/dist/core/agents/qa-signal.d.ts +111 -0
- package/dist/core/agents/qa-signal.d.ts.map +1 -0
- package/dist/core/agents/qa-signal.js +231 -0
- package/dist/core/agents/session-name-display.d.ts +20 -5
- package/dist/core/agents/session-name-display.d.ts.map +1 -1
- package/dist/core/agents/session-name-display.js +67 -7
- package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +17 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +1 -0
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/artifacts/constants.d.ts +1 -1
- package/dist/core/artifacts/constants.js +1 -1
- package/dist/core/artifacts/index.d.ts +57 -6
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +265 -10
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +16 -0
- package/dist/core/diagnostics/bundle.d.ts +16 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +101 -7
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +10 -0
- package/dist/core/events/v3/coordination-view.d.ts +3 -0
- package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
- package/dist/core/events/v3/coordination-view.js +69 -8
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +9 -2
- package/dist/core/events/v3/producers/recorder.d.ts +23 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +264 -18
- package/dist/core/hooks/cli.js +120 -27
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +10 -3
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
- package/dist/core/hooks/session-name-presence.js +4 -1
- package/dist/core/qa-artifacts.d.ts +20 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -0
- package/dist/core/qa-artifacts.js +110 -0
- package/dist/core/resources/contract.d.ts +6 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/sampler.d.ts +7 -0
- package/dist/core/resources/sampler.d.ts.map +1 -1
- package/dist/core/resources/sampler.js +106 -5
- package/dist/lib/admission.d.ts +71 -0
- package/dist/lib/admission.d.ts.map +1 -0
- package/dist/lib/admission.js +264 -0
- package/dist/lib/agent-browser/client.d.ts +1 -1
- package/dist/lib/agent-browser/client.d.ts.map +1 -1
- package/dist/lib/agent-browser/client.js +1 -5
- package/dist/lib/browser/capture-fidelity.d.ts +39 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
- package/dist/lib/browser/capture-fidelity.js +84 -0
- package/dist/lib/browser/client.d.ts +41 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +167 -9
- package/dist/lib/browser/critique.d.ts +38 -1
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +34 -6
- package/dist/lib/browser/index.d.ts +4 -2
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/page-review-judge.d.ts +64 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
- package/dist/lib/browser/page-review-judge.js +270 -0
- package/dist/lib/browser/page-review-pack.d.ts +613 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
- package/dist/lib/browser/page-review-pack.js +1751 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +136 -1
- package/dist/lib/browser/qa-run.d.ts +100 -10
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +768 -169
- package/dist/lib/browser/request-diagnostics.d.ts +13 -0
- package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
- package/dist/lib/browser/request-diagnostics.js +18 -0
- package/dist/lib/browser/tiling.d.ts +19 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +28 -0
- package/dist/lib/cookies/client.d.ts +9 -0
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +197 -44
- package/dist/lib/cookies/extra.d.ts +18 -0
- package/dist/lib/cookies/extra.d.ts.map +1 -0
- package/dist/lib/cookies/extra.js +14 -0
- package/dist/lib/cookies/index.d.ts +2 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +2 -1
- package/dist/lib/durable-job.d.ts +124 -0
- package/dist/lib/durable-job.d.ts.map +1 -0
- package/dist/lib/durable-job.js +296 -0
- package/dist/lib/http/client.d.ts +7 -1
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +2 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +5 -2
- package/package.json +8 -2
- package/src/commander.ts +50 -2
- package/src/commands/admission.ts +699 -0
- package/src/commands/agents.ts +87 -1
- package/src/commands/artifacts.ts +97 -21
- package/src/commands/browse-ai.ts +10 -5
- package/src/commands/browse.ts +481 -20
- package/src/commands/fetch.ts +1 -0
- package/src/commands/qa-record.ts +682 -0
- package/src/commands/qa-run.ts +335 -16
- package/src/commands/qa-status.ts +608 -0
- package/src/commands/qa-verify.ts +238 -0
- package/src/commands/review-pack.ts +1281 -0
- package/src/core/agents/qa-signal.ts +261 -0
- package/src/core/agents/session-name-display.ts +78 -7
- package/src/core/agents/state/heartbeat-reader.ts +7 -0
- package/src/core/agents/state/heartbeat-writer.ts +23 -0
- package/src/core/agents/state/live-coordination-view.ts +1 -0
- package/src/core/agents/state/live-coordination-writer.ts +5 -0
- package/src/core/artifacts/constants.ts +1 -1
- package/src/core/artifacts/index.ts +370 -21
- package/src/core/config.ts +23 -0
- package/src/core/diagnostics/bundle.ts +119 -11
- package/src/core/events/v3/bootstrap.ts +10 -0
- package/src/core/events/v3/coordination-view.ts +100 -11
- package/src/core/events/v3/producers/intake.ts +9 -2
- package/src/core/events/v3/producers/recorder.ts +312 -18
- package/src/core/hooks/cli.ts +140 -32
- package/src/core/hooks/resolve/transcript.ts +10 -3
- package/src/core/hooks/session-name-presence.ts +6 -1
- package/src/core/qa-artifacts.ts +126 -0
- package/src/core/resources/contract.ts +7 -0
- package/src/core/resources/sampler.ts +138 -6
- package/src/lib/admission.ts +347 -0
- package/src/lib/agent-browser/client.ts +2 -10
- package/src/lib/browser/capture-fidelity.ts +98 -0
- package/src/lib/browser/client.ts +206 -10
- package/src/lib/browser/critique.ts +62 -7
- package/src/lib/browser/index.ts +36 -0
- package/src/lib/browser/page-review-judge.ts +360 -0
- package/src/lib/browser/page-review-pack.ts +2384 -0
- package/src/lib/browser/qa-run-contracts.ts +366 -3
- package/src/lib/browser/qa-run.ts +868 -190
- package/src/lib/browser/request-diagnostics.ts +27 -0
- package/src/lib/browser/tiling.ts +32 -0
- package/src/lib/cookies/client.ts +228 -42
- package/src/lib/cookies/extra.ts +28 -0
- package/src/lib/cookies/index.ts +2 -0
- package/src/lib/durable-job.ts +407 -0
- package/src/lib/http/client.ts +13 -1
- 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
|
|
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 (
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/lib/browser/index.ts
CHANGED
|
@@ -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";
|