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
package/src/commands/browse.ts
CHANGED
|
@@ -2,8 +2,16 @@ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSyn
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
|
+
import { PNG } from "pngjs";
|
|
5
6
|
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
6
7
|
import { resolveBinName } from "../core/config.ts";
|
|
8
|
+
import {
|
|
9
|
+
CAPTURE_FIDELITY_MISMATCH_THRESHOLD,
|
|
10
|
+
compareBand,
|
|
11
|
+
decideFidelity,
|
|
12
|
+
type FidelityProbe,
|
|
13
|
+
pngDimensions,
|
|
14
|
+
} from "../lib/browser/capture-fidelity.ts";
|
|
7
15
|
import {
|
|
8
16
|
type AssertResult,
|
|
9
17
|
Browser,
|
|
@@ -11,6 +19,7 @@ import {
|
|
|
11
19
|
browserProxyGateFromEnv,
|
|
12
20
|
type ContentAnnotationBox,
|
|
13
21
|
type ContentChecksResult,
|
|
22
|
+
type CritiqueCoverage,
|
|
14
23
|
type CritiqueResult,
|
|
15
24
|
type CritiqueTile,
|
|
16
25
|
captureDevOverlay,
|
|
@@ -34,6 +43,16 @@ import {
|
|
|
34
43
|
writeNetscapeCookieFile,
|
|
35
44
|
wslHeadedLaunchArgs,
|
|
36
45
|
} from "../lib/browser/index.ts";
|
|
46
|
+
import {
|
|
47
|
+
findPackTile,
|
|
48
|
+
type PageReviewCaptureFidelity,
|
|
49
|
+
type PageReviewContextRecord,
|
|
50
|
+
type PageReviewExpandedTileRecord,
|
|
51
|
+
readPackContext,
|
|
52
|
+
tileId,
|
|
53
|
+
writePackContext,
|
|
54
|
+
writePackExpandedTile,
|
|
55
|
+
} from "../lib/browser/page-review-pack.ts";
|
|
37
56
|
import {
|
|
38
57
|
buildQaManifest,
|
|
39
58
|
classifySignatures,
|
|
@@ -62,7 +81,7 @@ import {
|
|
|
62
81
|
type SaveBaselineResult,
|
|
63
82
|
saveBaseline,
|
|
64
83
|
} from "../lib/browser/visual-diff.ts";
|
|
65
|
-
import { CookieJar } from "../lib/cookies/index.ts";
|
|
84
|
+
import { applyExtraCookies, CookieJar } from "../lib/cookies/index.ts";
|
|
66
85
|
|
|
67
86
|
/**
|
|
68
87
|
* `harn browse <url>`: Playwright-backed page navigation with shared
|
|
@@ -117,6 +136,7 @@ interface BrowseOpts {
|
|
|
117
136
|
store?: string;
|
|
118
137
|
profile?: string;
|
|
119
138
|
viewport?: string;
|
|
139
|
+
deviceScaleFactor?: string;
|
|
120
140
|
colorScheme?: string;
|
|
121
141
|
waitUntil: string;
|
|
122
142
|
timeout: string;
|
|
@@ -211,6 +231,16 @@ interface BrowseOpts {
|
|
|
211
231
|
qaStates?: string[];
|
|
212
232
|
qaReuse?: boolean;
|
|
213
233
|
qaReuseThreshold?: string;
|
|
234
|
+
// Page review pack capture (page-review-pack.ts): tiles + DOM to disk, no
|
|
235
|
+
// vision call; the judge stage reviews the pack after the browser closed.
|
|
236
|
+
reviewPack?: string;
|
|
237
|
+
reviewPackContext?: string;
|
|
238
|
+
reviewPackScope?: string[];
|
|
239
|
+
reviewPackBands?: boolean;
|
|
240
|
+
/** Re-capture ONE existing tile of --review-pack-context at --device-scale-factor. */
|
|
241
|
+
reviewPackExpand?: string;
|
|
242
|
+
/** Gate-hit rectangles (`x,y,w,h` strings) whose bands are captured past the tile cap. */
|
|
243
|
+
reviewPackHitRect?: string[];
|
|
214
244
|
// Next.js dev-overlay capture (auto-on; --no-dev-overlay opts out)
|
|
215
245
|
devOverlay?: boolean;
|
|
216
246
|
}
|
|
@@ -688,6 +718,48 @@ export function registerBrowseCommand(
|
|
|
688
718
|
"Band-diff mismatch ratio at or below which a clean tile region is reused " +
|
|
689
719
|
`(default ${DEFAULT_REUSE_MISMATCH_RATIO}; lower it toward 0 for stricter reuse).`,
|
|
690
720
|
)
|
|
721
|
+
.option(
|
|
722
|
+
"--review-pack <dir>",
|
|
723
|
+
"Capture this render into a page review pack at <dir> (contexts/<id>/: full-page " +
|
|
724
|
+
"screenshot, critique tiles as PNG files, serialized DOM, QA signature) and make no " +
|
|
725
|
+
"vision call. A judge stage (`review-pack judge`, or qa-run) reviews the tiles from disk " +
|
|
726
|
+
"after every browser has closed. Tiling knobs are the --check-critique-* flags.",
|
|
727
|
+
)
|
|
728
|
+
.option(
|
|
729
|
+
"--review-pack-context <id>",
|
|
730
|
+
"Context id inside the pack (default <viewport>-<theme>-<state> from --viewport, " +
|
|
731
|
+
"--qa-theme, --qa-state).",
|
|
732
|
+
)
|
|
733
|
+
.option(
|
|
734
|
+
"--review-pack-scope <selector>",
|
|
735
|
+
"Also tile one screenshot per element matching <selector> into the pack (repeatable).",
|
|
736
|
+
(value: string, previous: string[] = []) => [...previous, value],
|
|
737
|
+
[] as string[],
|
|
738
|
+
)
|
|
739
|
+
.option(
|
|
740
|
+
"--no-review-pack-bands",
|
|
741
|
+
"Skip full-page bands in the pack and keep only --review-pack-scope tiles (a scoped " +
|
|
742
|
+
"visual pass).",
|
|
743
|
+
)
|
|
744
|
+
.option(
|
|
745
|
+
"--review-pack-expand <tile-id>",
|
|
746
|
+
"Instead of capturing a context, re-render ONE existing tile of --review-pack-context " +
|
|
747
|
+
"(e.g. T012) at --device-scale-factor and write it beside the original as " +
|
|
748
|
+
"tiles/<tile>@<dpr>x.png. Existing tiles are untouched.",
|
|
749
|
+
)
|
|
750
|
+
.option(
|
|
751
|
+
"--review-pack-hit-rect <x,y,w,h>",
|
|
752
|
+
"Document-space rectangle (integer px) a deterministic gate already flagged. Every " +
|
|
753
|
+
"full-page band that contains it is captured even past --check-critique-max-tiles, " +
|
|
754
|
+
`labelled \`hit band N\` (repeatable, at most ${REVIEW_PACK_HIT_RECT_MAX}).`,
|
|
755
|
+
(value: string, previous: string[] = []) => [...previous, value],
|
|
756
|
+
[] as string[],
|
|
757
|
+
)
|
|
758
|
+
.option(
|
|
759
|
+
"--device-scale-factor <n>",
|
|
760
|
+
"Device scale factor for the browser context (default 1). 2 renders every screenshot " +
|
|
761
|
+
"at twice the pixels; pair with --review-pack-expand for a sharper look at one tile.",
|
|
762
|
+
)
|
|
691
763
|
.option(
|
|
692
764
|
"--no-dev-overlay",
|
|
693
765
|
"Skip auto-capture of Next.js dev-overlay issues. Default: capture every queued error (kind/code/message/stack) when a <nextjs-portal> shadow root is present. Necessary because Next.js 16 + React 19 route hydration errors + most React warnings through onCaughtError → next-devtools' errorQueue, NOT through console.error, so Playwright's standard listener doesn't see them. Surfaces them in the JSON envelope under `devOverlay`.",
|
|
@@ -739,8 +811,19 @@ async function runBrowse(
|
|
|
739
811
|
opts.cookies === false
|
|
740
812
|
? null
|
|
741
813
|
: new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" });
|
|
814
|
+
if (jar) applyExtraCookies(url, jar, context?.extraCookies);
|
|
742
815
|
const headed = opts.login || opts.headed;
|
|
743
816
|
const viewport = parseViewport(opts.viewport ?? "desktop");
|
|
817
|
+
const deviceScaleFactor = parseDeviceScaleFactor(opts.deviceScaleFactor);
|
|
818
|
+
if (opts.reviewPackExpand !== undefined) {
|
|
819
|
+
if (opts.reviewPack === undefined || opts.reviewPackContext === undefined) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
"--review-pack-expand requires --review-pack <dir> and --review-pack-context <id>.",
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Malformed hit rects fail here, before a browser launches.
|
|
826
|
+
parseReviewPackHitRects(opts.reviewPackHitRect);
|
|
744
827
|
let colorScheme: "light" | "dark" | undefined;
|
|
745
828
|
if (opts.colorScheme !== undefined) {
|
|
746
829
|
if (opts.colorScheme !== "light" && opts.colorScheme !== "dark") {
|
|
@@ -768,6 +851,7 @@ async function runBrowse(
|
|
|
768
851
|
headed,
|
|
769
852
|
jar,
|
|
770
853
|
viewport,
|
|
854
|
+
...(deviceScaleFactor !== undefined ? { deviceScaleFactor } : {}),
|
|
771
855
|
...(colorScheme ? { colorScheme } : {}),
|
|
772
856
|
navigationTimeout: Number.parseInt(opts.timeout, 10),
|
|
773
857
|
waitUntil: opts.waitUntil as BrowseOpts["waitUntil"] as never,
|
|
@@ -994,12 +1078,25 @@ async function runBrowse(
|
|
|
994
1078
|
"info",
|
|
995
1079
|
);
|
|
996
1080
|
}
|
|
997
|
-
critique =
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1081
|
+
critique = {
|
|
1082
|
+
...(await runCritique({
|
|
1083
|
+
url: navResult.url,
|
|
1084
|
+
rubric,
|
|
1085
|
+
tiles: tilesToReview,
|
|
1086
|
+
provider: critiqueProvider,
|
|
1087
|
+
})),
|
|
1088
|
+
// Coverage rides on every envelope, skipped or not, so a reader can
|
|
1089
|
+
// tell a pass over the whole page from a pass over its first N bands.
|
|
1090
|
+
coverage: captured.coverage,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
// Page review pack capture: tiles, DOM, and signature to disk, no vision
|
|
1094
|
+
// call. Also before annotations, for the same reason as the critique.
|
|
1095
|
+
let reviewPack: ReviewPackReport | undefined;
|
|
1096
|
+
if (opts.reviewPack !== undefined && opts.reviewPackExpand !== undefined) {
|
|
1097
|
+
reviewPack = await expandReviewPackTile(browser, opts, deviceScaleFactor ?? 1);
|
|
1098
|
+
} else if (opts.reviewPack !== undefined) {
|
|
1099
|
+
reviewPack = await captureReviewPackContext(browser, url, navResult, opts, qaCapture);
|
|
1003
1100
|
}
|
|
1004
1101
|
// Persist the QA baseline AFTER critique so a passing run's verdicts ride
|
|
1005
1102
|
// along with the snapshot (still before annotations mutate the page).
|
|
@@ -1012,6 +1109,7 @@ async function runBrowse(
|
|
|
1012
1109
|
critique?.provider &&
|
|
1013
1110
|
critique.outcome !== "skipped" &&
|
|
1014
1111
|
critiqueTiles !== undefined &&
|
|
1112
|
+
!critique.coverage?.capped &&
|
|
1015
1113
|
(!qaReuse || qaReuse.tiles_reused === 0);
|
|
1016
1114
|
const persistedCritique: PersistedCritique | undefined =
|
|
1017
1115
|
fullCoverage && critique && critiqueTiles
|
|
@@ -1175,6 +1273,7 @@ async function runBrowse(
|
|
|
1175
1273
|
batchResult,
|
|
1176
1274
|
qaPlan,
|
|
1177
1275
|
qaReuse ? summarizeReuse(qaReuse) : undefined,
|
|
1276
|
+
reviewPack,
|
|
1178
1277
|
);
|
|
1179
1278
|
} else {
|
|
1180
1279
|
await runTrioMode(
|
|
@@ -1195,6 +1294,7 @@ async function runBrowse(
|
|
|
1195
1294
|
batchResult,
|
|
1196
1295
|
qaPlan,
|
|
1197
1296
|
qaReuse ? summarizeReuse(qaReuse) : undefined,
|
|
1297
|
+
reviewPack,
|
|
1198
1298
|
);
|
|
1199
1299
|
}
|
|
1200
1300
|
|
|
@@ -1440,6 +1540,7 @@ async function runPrintMode(
|
|
|
1440
1540
|
batchResult: BatchResult | undefined,
|
|
1441
1541
|
qaPlan: QaPlanReport | undefined,
|
|
1442
1542
|
qaReuse: Record<string, unknown> | undefined,
|
|
1543
|
+
reviewPack: ReviewPackReport | undefined,
|
|
1443
1544
|
): Promise<void> {
|
|
1444
1545
|
let body: string | null = null;
|
|
1445
1546
|
if (opts.html) {
|
|
@@ -1476,6 +1577,7 @@ async function runPrintMode(
|
|
|
1476
1577
|
if (devOverlay) result.devOverlay = devOverlay;
|
|
1477
1578
|
if (qaPlan) result.qaPlan = qaPlan;
|
|
1478
1579
|
if (qaReuse) result.qaReuse = qaReuse;
|
|
1580
|
+
if (reviewPack) result.reviewPack = reviewPack;
|
|
1479
1581
|
if (batchResult && batchResult.clipboardReads.length > 0) {
|
|
1480
1582
|
result.batchClipboardReads = batchResult.clipboardReads;
|
|
1481
1583
|
}
|
|
@@ -1514,6 +1616,7 @@ async function runTrioMode(
|
|
|
1514
1616
|
batchResult: BatchResult | undefined,
|
|
1515
1617
|
qaPlan: QaPlanReport | undefined,
|
|
1516
1618
|
qaReuse: Record<string, unknown> | undefined,
|
|
1619
|
+
reviewPack: ReviewPackReport | undefined,
|
|
1517
1620
|
): Promise<void> {
|
|
1518
1621
|
const prefix = resolveOutPrefix(opts.out);
|
|
1519
1622
|
mkdirSync(dirname(prefix), { recursive: true });
|
|
@@ -1610,6 +1713,7 @@ async function runTrioMode(
|
|
|
1610
1713
|
if (devOverlay) envelope.devOverlay = devOverlay;
|
|
1611
1714
|
if (qaPlan) envelope.qaPlan = qaPlan;
|
|
1612
1715
|
if (qaReuse) envelope.qaReuse = qaReuse;
|
|
1716
|
+
if (reviewPack) envelope.reviewPack = reviewPack;
|
|
1613
1717
|
if (batchResult && batchResult.clipboardReads.length > 0) {
|
|
1614
1718
|
envelope.batchClipboardReads = batchResult.clipboardReads;
|
|
1615
1719
|
}
|
|
@@ -2103,12 +2207,14 @@ interface QaCaptureState {
|
|
|
2103
2207
|
* supplies the live-page inputs (signature, page height, explicit scope match
|
|
2104
2208
|
* counts). Classification and manifest math live in lib/browser/qa-plan.ts.
|
|
2105
2209
|
*/
|
|
2106
|
-
|
|
2210
|
+
/** Capture the live page's QA signature and serialized DOM for the store key
|
|
2211
|
+
* `--qa-target` (default: the url argument) and the labelled context. */
|
|
2212
|
+
async function captureQaState(
|
|
2107
2213
|
browser: Browser,
|
|
2108
2214
|
targetArg: string,
|
|
2109
2215
|
renderedUrl: string,
|
|
2110
2216
|
opts: BrowseOpts,
|
|
2111
|
-
): Promise<
|
|
2217
|
+
): Promise<QaCaptureState> {
|
|
2112
2218
|
const context: QaContext = {
|
|
2113
2219
|
viewport: opts.viewport ?? "desktop",
|
|
2114
2220
|
theme: opts.qaTheme === "dark" ? "dark" : "light",
|
|
@@ -2123,11 +2229,22 @@ async function runQaPlanning(
|
|
|
2123
2229
|
stylesheets: captured.stylesheets,
|
|
2124
2230
|
...(captured.truncated ? { truncated: true } : {}),
|
|
2125
2231
|
};
|
|
2232
|
+
return { target, context, signature, domHtml: captured.domHtml };
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
async function runQaPlanning(
|
|
2236
|
+
browser: Browser,
|
|
2237
|
+
targetArg: string,
|
|
2238
|
+
renderedUrl: string,
|
|
2239
|
+
opts: BrowseOpts,
|
|
2240
|
+
): Promise<{ report: QaPlanReport; capture: QaCaptureState }> {
|
|
2241
|
+
const capture = await captureQaState(browser, targetArg, renderedUrl, opts);
|
|
2242
|
+
const { target, context, signature } = capture;
|
|
2126
2243
|
const report: QaPlanReport = {
|
|
2127
2244
|
signature: {
|
|
2128
|
-
nodes:
|
|
2129
|
-
stylesheets:
|
|
2130
|
-
truncated:
|
|
2245
|
+
nodes: signature.nodes.length,
|
|
2246
|
+
stylesheets: signature.stylesheets.length,
|
|
2247
|
+
truncated: Boolean(signature.truncated),
|
|
2131
2248
|
},
|
|
2132
2249
|
};
|
|
2133
2250
|
|
|
@@ -2165,20 +2282,324 @@ async function runQaPlanning(
|
|
|
2165
2282
|
});
|
|
2166
2283
|
}
|
|
2167
2284
|
|
|
2168
|
-
return { report, capture
|
|
2285
|
+
return { report, capture };
|
|
2169
2286
|
}
|
|
2170
2287
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2288
|
+
/** Tiling knobs shared by the critique and the review-pack capture. The
|
|
2289
|
+
* routed provider's vision long-edge budget caps band height so tiles are
|
|
2290
|
+
* never silently downscaled by the model. */
|
|
2291
|
+
function critiqueTilingKnobs(
|
|
2173
2292
|
opts: BrowseOpts,
|
|
2174
2293
|
tileBudgetPx?: number,
|
|
2175
|
-
):
|
|
2294
|
+
): { maxTiles: number; band: number; overlap: number } {
|
|
2176
2295
|
const maxTiles = Math.max(1, Number.parseInt(opts.checkCritiqueMaxTiles ?? "24", 10));
|
|
2177
|
-
// The routed provider's vision long-edge budget caps band height so tiles
|
|
2178
|
-
// are never silently downscaled by the model.
|
|
2179
2296
|
const bandFlag = Math.max(200, Number.parseInt(opts.checkCritiqueBand ?? "1400", 10));
|
|
2180
2297
|
const band = tileBudgetPx ? Math.max(200, Math.min(bandFlag, tileBudgetPx)) : bandFlag;
|
|
2181
2298
|
const overlap = Math.max(0, Number.parseInt(opts.checkCritiqueOverlap ?? "120", 10));
|
|
2299
|
+
return { maxTiles, band, overlap };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/** What `--review-pack` puts in the JSON envelope. */
|
|
2303
|
+
interface ReviewPackReport {
|
|
2304
|
+
dir: string;
|
|
2305
|
+
context_id: string;
|
|
2306
|
+
tiles: number;
|
|
2307
|
+
scopes: Array<{ selector: string; tiles: number }>;
|
|
2308
|
+
coverage: CritiqueCoverage;
|
|
2309
|
+
files: PageReviewContextRecord["files"];
|
|
2310
|
+
/** Bands captured past the tile cap for --review-pack-hit-rect rectangles. */
|
|
2311
|
+
hit_bands?: number;
|
|
2312
|
+
/** Where the tiles came from and what the fidelity probe saw. */
|
|
2313
|
+
capture_fidelity?: PageReviewCaptureFidelity;
|
|
2314
|
+
/** Capture warnings worth surfacing beside coverage (fidelity fallback, failed probes). */
|
|
2315
|
+
warnings?: string[];
|
|
2316
|
+
/** Set by --review-pack-expand: the one region that was re-captured. */
|
|
2317
|
+
expanded?: PageReviewExpandedTileRecord;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* `--review-pack-expand`: re-render one existing tile of a pack context at
|
|
2322
|
+
* the context's device scale factor. The tile's CSS-pixel rect is captured by
|
|
2323
|
+
* scrolling the viewport to it and clipping (`captureRegionByScroll`), which
|
|
2324
|
+
* renders the region the way a reader sees it and yields tile size × DPR
|
|
2325
|
+
* pixels. A second full-page screenshot is not taken: it would reproduce the
|
|
2326
|
+
* very raster artifact the expand is meant to check. No other pack file
|
|
2327
|
+
* changes.
|
|
2328
|
+
*/
|
|
2329
|
+
async function expandReviewPackTile(
|
|
2330
|
+
browser: Browser,
|
|
2331
|
+
opts: BrowseOpts,
|
|
2332
|
+
dpr: number,
|
|
2333
|
+
): Promise<ReviewPackReport> {
|
|
2334
|
+
const packDir = opts.reviewPack as string;
|
|
2335
|
+
const contextId = opts.reviewPackContext as string;
|
|
2336
|
+
const tileId = opts.reviewPackExpand as string;
|
|
2337
|
+
const { tile } = findPackTile([readPackContext(packDir, contextId)], tileId, contextId);
|
|
2338
|
+
const region = await browser.captureRegionByScroll({
|
|
2339
|
+
x: tile.x,
|
|
2340
|
+
y: tile.scrollY,
|
|
2341
|
+
width: tile.width,
|
|
2342
|
+
height: tile.height,
|
|
2343
|
+
});
|
|
2344
|
+
const { record, expanded } = writePackExpandedTile(packDir, contextId, {
|
|
2345
|
+
tileId,
|
|
2346
|
+
dpr,
|
|
2347
|
+
region,
|
|
2348
|
+
});
|
|
2349
|
+
emit.log(
|
|
2350
|
+
`review-pack: ${record.id}/${tileId} expanded at ${dpr}× → ${expanded.width}×${expanded.height} px, ${expanded.file}`,
|
|
2351
|
+
"info",
|
|
2352
|
+
);
|
|
2353
|
+
return {
|
|
2354
|
+
dir: packDir,
|
|
2355
|
+
context_id: record.id,
|
|
2356
|
+
tiles: record.tiles.length,
|
|
2357
|
+
scopes: record.scopes,
|
|
2358
|
+
coverage: record.coverage,
|
|
2359
|
+
files: record.files,
|
|
2360
|
+
expanded,
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Capture-fidelity probe (capture-fidelity.ts). Every band is re-shot by
|
|
2366
|
+
* scrolling the viewport (`Browser.captureRegionByScroll`) and pixel compared
|
|
2367
|
+
* with the crop the tiler took from the full-page screenshot. When they all
|
|
2368
|
+
* agree the tiles stand and the record says `full-page`; when any disagrees,
|
|
2369
|
+
* every band is re-cut from its scrolled capture (same rects, same ids, only
|
|
2370
|
+
* the pixels change) and the record says `scrolled-bands`. A probe that
|
|
2371
|
+
* cannot run, or a re-capture that comes back the wrong size (a page wider
|
|
2372
|
+
* than the viewport), keeps the full-page crop for that band and says so in a
|
|
2373
|
+
* warning. Runs while the capture browser is open; the judge never opens one.
|
|
2374
|
+
*/
|
|
2375
|
+
async function reconcileCaptureFidelity(
|
|
2376
|
+
browser: Browser,
|
|
2377
|
+
tiles: CritiqueTile[],
|
|
2378
|
+
): Promise<{ tiles: CritiqueTile[]; fidelity: PageReviewCaptureFidelity; warnings: string[] }> {
|
|
2379
|
+
const threshold = CAPTURE_FIDELITY_MISMATCH_THRESHOLD;
|
|
2380
|
+
const warnings: string[] = [];
|
|
2381
|
+
const dpr = await browser.currentPage.evaluate(() => window.devicePixelRatio || 1);
|
|
2382
|
+
// Tile rects live in the full-page PNG's pixel space; the scroll capture
|
|
2383
|
+
// takes CSS px and renders at the page's DPR, so the two line up again.
|
|
2384
|
+
const cssRect = (tile: CritiqueTile) => ({
|
|
2385
|
+
x: (tile.x ?? 0) / dpr,
|
|
2386
|
+
y: tile.scrollY / dpr,
|
|
2387
|
+
width: tile.width / dpr,
|
|
2388
|
+
height: tile.height / dpr,
|
|
2389
|
+
});
|
|
2390
|
+
const shots = new Map<number, Buffer | undefined>();
|
|
2391
|
+
const shoot = async (position: number): Promise<Buffer | undefined> => {
|
|
2392
|
+
if (shots.has(position)) return shots.get(position);
|
|
2393
|
+
const tile = tiles[position] as CritiqueTile;
|
|
2394
|
+
let shot: Buffer | undefined;
|
|
2395
|
+
try {
|
|
2396
|
+
const captured = await browser.captureRegionByScroll(cssRect(tile));
|
|
2397
|
+
const size = pngDimensions(captured);
|
|
2398
|
+
if (Math.abs(size.width - tile.width) > 1 || Math.abs(size.height - tile.height) > 1) {
|
|
2399
|
+
warnings.push(
|
|
2400
|
+
`review-pack: scrolled capture of ${tileId(position)} came back ` +
|
|
2401
|
+
`${size.width}×${size.height} px for a ${tile.width}×${tile.height} px band; ` +
|
|
2402
|
+
"its full-page crop was kept",
|
|
2403
|
+
);
|
|
2404
|
+
} else {
|
|
2405
|
+
shot = captured;
|
|
2406
|
+
}
|
|
2407
|
+
} catch (err: unknown) {
|
|
2408
|
+
warnings.push(
|
|
2409
|
+
`review-pack: scrolled capture of ${tileId(position)} failed ` +
|
|
2410
|
+
`(${err instanceof Error ? err.message : String(err)}); its full-page crop was kept`,
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
shots.set(position, shot);
|
|
2414
|
+
return shot;
|
|
2415
|
+
};
|
|
2416
|
+
// Every band is probed. A two-band sample (top and middle) missed a raster
|
|
2417
|
+
// artifact that sat elsewhere on the page, and once every band has been
|
|
2418
|
+
// shot by scrolling, the fallback below costs nothing extra: the same shots
|
|
2419
|
+
// become the tiles.
|
|
2420
|
+
const probes: FidelityProbe[] = [];
|
|
2421
|
+
for (let position = 0; position < tiles.length; position++) {
|
|
2422
|
+
const tile = tiles[position] as CritiqueTile;
|
|
2423
|
+
const shot = await shoot(position);
|
|
2424
|
+
if (!shot) continue;
|
|
2425
|
+
const cmp = compareBand(
|
|
2426
|
+
PNG.sync.read(Buffer.from(tile.pngBase64, "base64")),
|
|
2427
|
+
PNG.sync.read(shot),
|
|
2428
|
+
threshold,
|
|
2429
|
+
);
|
|
2430
|
+
probes.push({
|
|
2431
|
+
tile_id: tileId(position),
|
|
2432
|
+
scrollY: tile.scrollY,
|
|
2433
|
+
height: tile.height,
|
|
2434
|
+
mismatch_ratio: cmp.mismatch_ratio,
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
const fidelity = decideFidelity(probes, threshold);
|
|
2438
|
+
if (fidelity.source === "full-page") return { tiles, fidelity, warnings };
|
|
2439
|
+
const recut: CritiqueTile[] = [];
|
|
2440
|
+
for (let position = 0; position < tiles.length; position++) {
|
|
2441
|
+
const tile = tiles[position] as CritiqueTile;
|
|
2442
|
+
const shot = await shoot(position);
|
|
2443
|
+
recut.push(shot ? { ...tile, pngBase64: shot.toString("base64") } : tile);
|
|
2444
|
+
}
|
|
2445
|
+
warnings.unshift(
|
|
2446
|
+
`review-pack: the full-page screenshot disagreed with the viewport render on ` +
|
|
2447
|
+
`${fidelity.mismatched.join(", ")} (mismatch ratio above ${threshold}); every band was ` +
|
|
2448
|
+
"re-captured by scrolling the viewport",
|
|
2449
|
+
);
|
|
2450
|
+
return { tiles: recut, fidelity, warnings };
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* Capture this render into a page review pack: one full-page screenshot,
|
|
2455
|
+
* banded tiles (unless --no-review-pack-bands), one tile set per
|
|
2456
|
+
* --review-pack-scope selector, the serialized DOM, and the QA signature —
|
|
2457
|
+
* all written to disk, with no vision call. The judge stage reads them back
|
|
2458
|
+
* once the browser is gone.
|
|
2459
|
+
*/
|
|
2460
|
+
async function captureReviewPackContext(
|
|
2461
|
+
browser: Browser,
|
|
2462
|
+
targetArg: string,
|
|
2463
|
+
navResult: { url: string; title: string },
|
|
2464
|
+
opts: BrowseOpts,
|
|
2465
|
+
qaCapture: QaCaptureState | undefined,
|
|
2466
|
+
): Promise<ReviewPackReport> {
|
|
2467
|
+
const packDir = opts.reviewPack as string;
|
|
2468
|
+
const { maxTiles, band, overlap } = critiqueTilingKnobs(opts);
|
|
2469
|
+
const buffer = await browser.fullPageScreenshotBuffer();
|
|
2470
|
+
let atoms: Awaited<ReturnType<Browser["visualAtoms"]>> | undefined;
|
|
2471
|
+
try {
|
|
2472
|
+
atoms = await browser.visualAtoms();
|
|
2473
|
+
} catch {
|
|
2474
|
+
atoms = undefined;
|
|
2475
|
+
}
|
|
2476
|
+
const tiling = { bandHeight: band, overlap, maxTiles, atoms };
|
|
2477
|
+
const withBands = opts.reviewPackBands !== false;
|
|
2478
|
+
// Gate-hit rectangles (qa-run lifts them from the gate stage, which runs
|
|
2479
|
+
// first) pull the bands they land in past the cap; those tiles follow the
|
|
2480
|
+
// kept bands as `hit band N`. Coverage still describes the kept prefix.
|
|
2481
|
+
const hitRects = parseReviewPackHitRects(opts.reviewPackHitRect);
|
|
2482
|
+
const banded = withBands
|
|
2483
|
+
? tilesFromFullPage(buffer, { ...tiling, hitRects, maxHitBands: REVIEW_PACK_HIT_RECT_MAX })
|
|
2484
|
+
: undefined;
|
|
2485
|
+
if (banded?.coverage.capped) {
|
|
2486
|
+
emit.log(
|
|
2487
|
+
`review-pack: page banded to the ${maxTiles}-tile cap (raise --check-critique-max-tiles for full coverage)`,
|
|
2488
|
+
"warn",
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
if (banded && banded.hitBands > 0) {
|
|
2492
|
+
emit.log(
|
|
2493
|
+
`review-pack: ${banded.hitBands} band(s) below the tile cap captured for ${hitRects.length} gate-hit rect(s)`,
|
|
2494
|
+
"info",
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
// Fidelity: prove the full-page screenshot against scrolled viewport
|
|
2498
|
+
// renders while the browser is still open; on disagreement the bands are
|
|
2499
|
+
// re-cut from scrolled captures (see reconcileCaptureFidelity).
|
|
2500
|
+
let bandTiles = banded?.tiles ?? [];
|
|
2501
|
+
let captureFidelity: PageReviewCaptureFidelity | undefined;
|
|
2502
|
+
const fidelityWarnings: string[] = [];
|
|
2503
|
+
if (bandTiles.length > 0) {
|
|
2504
|
+
const reconciled = await reconcileCaptureFidelity(browser, bandTiles);
|
|
2505
|
+
bandTiles = reconciled.tiles;
|
|
2506
|
+
captureFidelity = reconciled.fidelity;
|
|
2507
|
+
fidelityWarnings.push(...reconciled.warnings);
|
|
2508
|
+
for (const warning of reconciled.warnings) emit.log(warning, "warn");
|
|
2509
|
+
emit.log(
|
|
2510
|
+
`review-pack: capture fidelity ${captureFidelity.source} ` +
|
|
2511
|
+
`(${captureFidelity.probed.length} band(s) probed, ${captureFidelity.mismatched.length} mismatched)`,
|
|
2512
|
+
"info",
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
const scopeTiles: Array<{ selector: string; tiles: CritiqueTile[]; coverage: CritiqueCoverage }> =
|
|
2516
|
+
[];
|
|
2517
|
+
for (const selector of opts.reviewPackScope ?? []) {
|
|
2518
|
+
const elementRects = await browser.elementTiles(selector);
|
|
2519
|
+
const scoped = tilesFromFullPage(buffer, { ...tiling, elementRects });
|
|
2520
|
+
if (elementRects.length > maxTiles) {
|
|
2521
|
+
emit.log(
|
|
2522
|
+
`review-pack: ${elementRects.length} elements matched ${selector}, capped to ${maxTiles} tiles`,
|
|
2523
|
+
"warn",
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
scopeTiles.push({ selector, tiles: scoped.tiles, coverage: scoped.coverage });
|
|
2527
|
+
}
|
|
2528
|
+
// Coverage: the bands when present, else the scopes folded together
|
|
2529
|
+
// (heights worst-case, band counts add, capped sticky).
|
|
2530
|
+
let coverage: CritiqueCoverage | undefined = banded?.coverage;
|
|
2531
|
+
if (!coverage) {
|
|
2532
|
+
for (const scope of scopeTiles) {
|
|
2533
|
+
coverage = coverage
|
|
2534
|
+
? {
|
|
2535
|
+
page_height_px: Math.max(coverage.page_height_px, scope.coverage.page_height_px),
|
|
2536
|
+
reviewed_height_px: Math.min(
|
|
2537
|
+
coverage.reviewed_height_px,
|
|
2538
|
+
scope.coverage.reviewed_height_px,
|
|
2539
|
+
),
|
|
2540
|
+
bands_total: coverage.bands_total + scope.coverage.bands_total,
|
|
2541
|
+
bands_reviewed: coverage.bands_reviewed + scope.coverage.bands_reviewed,
|
|
2542
|
+
capped: coverage.capped || scope.coverage.capped,
|
|
2543
|
+
}
|
|
2544
|
+
: scope.coverage;
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
const page = await browser.currentPage.evaluate(() => ({
|
|
2548
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
2549
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
|
|
2550
|
+
}));
|
|
2551
|
+
const capture = qaCapture ?? (await captureQaState(browser, targetArg, navResult.url, opts));
|
|
2552
|
+
const record = writePackContext(packDir, {
|
|
2553
|
+
context: {
|
|
2554
|
+
...capture.context,
|
|
2555
|
+
...(opts.reviewPackContext ? { id: opts.reviewPackContext } : {}),
|
|
2556
|
+
},
|
|
2557
|
+
url: navResult.url,
|
|
2558
|
+
title: navResult.title,
|
|
2559
|
+
fullPage: buffer,
|
|
2560
|
+
pageWidth: page.width,
|
|
2561
|
+
pageHeight: page.height,
|
|
2562
|
+
tiles: bandTiles,
|
|
2563
|
+
coverage: coverage ?? {
|
|
2564
|
+
page_height_px: page.height,
|
|
2565
|
+
reviewed_height_px: 0,
|
|
2566
|
+
bands_total: 0,
|
|
2567
|
+
bands_reviewed: 0,
|
|
2568
|
+
capped: false,
|
|
2569
|
+
},
|
|
2570
|
+
scopeTiles: scopeTiles.map(({ selector, tiles }) => ({ selector, tiles })),
|
|
2571
|
+
signature: capture.signature,
|
|
2572
|
+
domHtml: capture.domHtml,
|
|
2573
|
+
...(captureFidelity ? { captureFidelity } : {}),
|
|
2574
|
+
hitBands: banded?.hitBands ?? 0,
|
|
2575
|
+
});
|
|
2576
|
+
emit.log(
|
|
2577
|
+
`review-pack: ${record.id} → ${record.tiles.length} tile(s)` +
|
|
2578
|
+
(record.scopes.length > 0
|
|
2579
|
+
? ` (${record.scopes.map((s) => `${s.selector}: ${s.tiles}`).join(", ")})`
|
|
2580
|
+
: "") +
|
|
2581
|
+
` written to ${packDir}`,
|
|
2582
|
+
"info",
|
|
2583
|
+
);
|
|
2584
|
+
return {
|
|
2585
|
+
dir: packDir,
|
|
2586
|
+
context_id: record.id,
|
|
2587
|
+
tiles: record.tiles.length,
|
|
2588
|
+
scopes: record.scopes,
|
|
2589
|
+
coverage: record.coverage,
|
|
2590
|
+
files: record.files,
|
|
2591
|
+
...(record.hit_bands !== undefined ? { hit_bands: record.hit_bands } : {}),
|
|
2592
|
+
...(record.capture_fidelity ? { capture_fidelity: record.capture_fidelity } : {}),
|
|
2593
|
+
...(fidelityWarnings.length > 0 ? { warnings: fidelityWarnings } : {}),
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
async function captureCritiqueTiles(
|
|
2598
|
+
browser: Browser,
|
|
2599
|
+
opts: BrowseOpts,
|
|
2600
|
+
tileBudgetPx?: number,
|
|
2601
|
+
): Promise<{ tiles: CritiqueTile[]; fullPage: Buffer; coverage: CritiqueCoverage }> {
|
|
2602
|
+
const { maxTiles, band, overlap } = critiqueTilingKnobs(opts, tileBudgetPx);
|
|
2182
2603
|
|
|
2183
2604
|
// Capture the whole page ONCE, then crop tiles from the pixels. Playwright's
|
|
2184
2605
|
// clip is viewport-relative, so a below-fold band clip throws ("clipped area
|
|
@@ -2198,7 +2619,7 @@ async function captureCritiqueTiles(
|
|
|
2198
2619
|
} catch {
|
|
2199
2620
|
atoms = undefined;
|
|
2200
2621
|
}
|
|
2201
|
-
const tiles = tilesFromFullPage(buffer, {
|
|
2622
|
+
const { tiles, coverage } = tilesFromFullPage(buffer, {
|
|
2202
2623
|
elementRects,
|
|
2203
2624
|
bandHeight: band,
|
|
2204
2625
|
overlap,
|
|
@@ -2217,7 +2638,7 @@ async function captureCritiqueTiles(
|
|
|
2217
2638
|
"warn",
|
|
2218
2639
|
);
|
|
2219
2640
|
}
|
|
2220
|
-
return { tiles, fullPage: buffer };
|
|
2641
|
+
return { tiles, fullPage: buffer, coverage };
|
|
2221
2642
|
}
|
|
2222
2643
|
function logCritiqueSummary(critique: CritiqueResult): void {
|
|
2223
2644
|
if (critique.outcome === "skipped") {
|
|
@@ -2314,6 +2735,46 @@ async function waitForTerminalEnter(signal: AbortSignal): Promise<void> {
|
|
|
2314
2735
|
});
|
|
2315
2736
|
}
|
|
2316
2737
|
|
|
2738
|
+
/** Upper bound on --review-pack-hit-rect values (and on hit bands cut past the cap). */
|
|
2739
|
+
const REVIEW_PACK_HIT_RECT_MAX = 50;
|
|
2740
|
+
|
|
2741
|
+
/** `x,y,w,h` integer rectangles from repeated --review-pack-hit-rect flags. */
|
|
2742
|
+
function parseReviewPackHitRects(
|
|
2743
|
+
specs: string[] | undefined,
|
|
2744
|
+
): Array<{ x: number; y: number; width: number; height: number }> {
|
|
2745
|
+
const list = specs ?? [];
|
|
2746
|
+
if (list.length > REVIEW_PACK_HIT_RECT_MAX) {
|
|
2747
|
+
throw new Error(
|
|
2748
|
+
`--review-pack-hit-rect accepts at most ${REVIEW_PACK_HIT_RECT_MAX} rectangles (got ${list.length}).`,
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
return list.map((spec) => {
|
|
2752
|
+
const parts = spec.split(",").map((part) => part.trim());
|
|
2753
|
+
const nums = parts.map((part) => (/^-?\d+$/.test(part) ? Number.parseInt(part, 10) : NaN));
|
|
2754
|
+
if (parts.length !== 4 || nums.some((n) => !Number.isFinite(n))) {
|
|
2755
|
+
throw new Error(
|
|
2756
|
+
`--review-pack-hit-rect must be four integers x,y,w,h in document px (got: ${spec}).`,
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
const [x, y, width, height] = nums as [number, number, number, number];
|
|
2760
|
+
if (width < 0 || height < 0) {
|
|
2761
|
+
throw new Error(
|
|
2762
|
+
`--review-pack-hit-rect width and height must not be negative (got: ${spec}).`,
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
return { x, y, width, height };
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
function parseDeviceScaleFactor(spec: string | undefined): number | undefined {
|
|
2770
|
+
if (spec === undefined) return undefined;
|
|
2771
|
+
const value = Number.parseFloat(spec);
|
|
2772
|
+
if (!Number.isFinite(value) || value < 1 || value > 4) {
|
|
2773
|
+
throw new Error(`--device-scale-factor must be a number between 1 and 4 (got: ${spec})`);
|
|
2774
|
+
}
|
|
2775
|
+
return value;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2317
2778
|
function parseViewport(spec: string): { width: number; height: number } {
|
|
2318
2779
|
const preset = VIEWPORT_PRESETS[spec.toLowerCase()];
|
|
2319
2780
|
if (preset) return preset;
|
package/src/commands/fetch.ts
CHANGED