@roku-sdk/device-test-client 0.0.0-stage → 2.18.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/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/baselines.md +221 -0
- package/docs/configuration.md +258 -0
- package/docs/dom-assertions.md +264 -0
- package/docs/errors.md +525 -0
- package/docs/examples/deep-link-cold-start.md +85 -0
- package/docs/examples/focus-navigation.md +81 -0
- package/docs/examples/log-driven-assertion.md +99 -0
- package/docs/examples/precondition-skips.md +150 -0
- package/docs/examples/screenshot-regression.md +103 -0
- package/docs/examples/smoke-test.md +69 -0
- package/docs/getting-started.md +195 -0
- package/docs/jest-integration.md +331 -0
- package/docs/log-assertions.md +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -3
- package/src/assertions/assertions.ts +661 -0
- package/src/assertions/baseline-env-guard.ts +43 -0
- package/src/assertions/baselines.ts +178 -0
- package/src/assertions/screenshot-artifacts.ts +39 -0
- package/src/assertions/screenshot-diff.ts +447 -0
- package/src/client/build-cache.ts +292 -0
- package/src/client/client.ts +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -0
- package/src/device/device-class.ts +57 -0
- package/src/device/device-env-sync.ts +58 -0
- package/src/device/device-resolver-sync.ts +43 -0
- package/src/device/device-resolver.ts +141 -0
- package/src/device/lease-provisioning.ts +54 -0
- package/src/errors.ts +773 -0
- package/src/index.ts +186 -0
- package/src/internal/constants.ts +3 -0
- package/src/jest/artifacts.ts +87 -0
- package/src/jest/device-test-environment.ts +420 -0
- package/src/jest/failure-artifacts.ts +169 -0
- package/src/jest/global-setup.ts +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -0
- package/src/jest/jest-setup.ts +53 -0
- package/src/jest/preconditions.ts +239 -0
- package/src/jest/telemetry.ts +331 -0
- package/src/logs/device-log-sink.ts +69 -0
- package/src/logs/log-buffer.ts +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +443 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Pure mask + diff helpers, shared by `captureStableScreenshot` and
|
|
5
|
+
* `expectScreenshot`. Leaf module (no I/O, no client coupling).
|
|
6
|
+
*
|
|
7
|
+
* `MaskRegion` coords are raw pixels of the resolved canvas (no dpi
|
|
8
|
+
* scaling); baselines are committed per-resolution.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PNG } from "pngjs";
|
|
12
|
+
|
|
13
|
+
import { ALIGN_TOLERANCE_MAX_PX } from "../constants";
|
|
14
|
+
import { ScreenshotAlignToleranceError, ScreenshotMaskError } from "../errors";
|
|
15
|
+
import type { AlignTolerance, MaskRegion } from "../types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize mask regions: floor coords/sizes (so a region never
|
|
19
|
+
* grows beyond intent), throw on zero-area or fully-OOB, clamp
|
|
20
|
+
* partial-OOB to the canvas intersection. The clamp lets a test
|
|
21
|
+
* authored against FHD survive an HD run, but the clamped region
|
|
22
|
+
* covers different absolute content on each resolution — callers
|
|
23
|
+
* needing resolution-specific masks must gate on `videoMode`.
|
|
24
|
+
*/
|
|
25
|
+
export function validateMasks(masks: MaskRegion[] | undefined, canvas: { w: number; h: number }): MaskRegion[] {
|
|
26
|
+
if (!masks || masks.length === 0) return [];
|
|
27
|
+
const out: MaskRegion[] = [];
|
|
28
|
+
for (const raw of masks) {
|
|
29
|
+
const x = Math.floor(raw.x);
|
|
30
|
+
const y = Math.floor(raw.y);
|
|
31
|
+
const w = Math.floor(raw.width);
|
|
32
|
+
const h = Math.floor(raw.height);
|
|
33
|
+
|
|
34
|
+
if (w <= 0 || h <= 0) {
|
|
35
|
+
throw new ScreenshotMaskError(
|
|
36
|
+
`Invalid mask region: width=${w}, height=${h}. ` +
|
|
37
|
+
`Width and height must both be positive integers after flooring.`,
|
|
38
|
+
{ region: raw, canvas, reason: "zero-area" }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
// Fully OOB: region has no intersection with the canvas
|
|
42
|
+
// (pixels are half-open on right/bottom).
|
|
43
|
+
if (x + w <= 0 || y + h <= 0 || x >= canvas.w || y >= canvas.h) {
|
|
44
|
+
throw new ScreenshotMaskError(
|
|
45
|
+
`Mask region is fully outside the ${canvas.w}x${canvas.h} canvas: ` +
|
|
46
|
+
`{x:${raw.x}, y:${raw.y}, width:${raw.width}, height:${raw.height}}.`,
|
|
47
|
+
{ region: raw, canvas, reason: "out-of-bounds" }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const clampedX = Math.max(0, x);
|
|
51
|
+
const clampedY = Math.max(0, y);
|
|
52
|
+
const clampedRight = Math.min(canvas.w, x + w);
|
|
53
|
+
const clampedBottom = Math.min(canvas.h, y + h);
|
|
54
|
+
out.push({
|
|
55
|
+
x: clampedX,
|
|
56
|
+
y: clampedY,
|
|
57
|
+
width: clampedRight - clampedX,
|
|
58
|
+
height: clampedBottom - clampedY
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Overwrite masked pixels with a solid RGBA fill. `fill` is 32-bit
|
|
66
|
+
* little-endian (byte 0 = R, 1 = G, 2 = B, 3 = A). `0x00000000` is
|
|
67
|
+
* transparent black — the default so diff PNGs show masked regions
|
|
68
|
+
* as a neutral patch. Caller passes validated regions; OOB regions
|
|
69
|
+
* are also clamped here defensively.
|
|
70
|
+
*/
|
|
71
|
+
export function applyMaskInPlace(png: PNG, masks: MaskRegion[], fill: number): void {
|
|
72
|
+
if (masks.length === 0) return;
|
|
73
|
+
const w = png.width;
|
|
74
|
+
const h = png.height;
|
|
75
|
+
const r = fill & 0xff;
|
|
76
|
+
const g = (fill >>> 8) & 0xff;
|
|
77
|
+
const b = (fill >>> 16) & 0xff;
|
|
78
|
+
const a = (fill >>> 24) & 0xff;
|
|
79
|
+
const data = png.data;
|
|
80
|
+
for (const m of masks) {
|
|
81
|
+
const x0 = Math.max(0, m.x);
|
|
82
|
+
const y0 = Math.max(0, m.y);
|
|
83
|
+
const x1 = Math.min(w, m.x + m.width);
|
|
84
|
+
const y1 = Math.min(h, m.y + m.height);
|
|
85
|
+
for (let y = y0; y < y1; y++) {
|
|
86
|
+
let off = (y * w + x0) * 4;
|
|
87
|
+
for (let x = x0; x < x1; x++) {
|
|
88
|
+
data[off] = r;
|
|
89
|
+
data[off + 1] = g;
|
|
90
|
+
data[off + 2] = b;
|
|
91
|
+
data[off + 3] = a;
|
|
92
|
+
off += 4;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pixel-by-pixel comparison outside the mask. We don't short-circuit
|
|
100
|
+
* on first miss: `ScreenshotNotStableError.lastDiffCount` surfaces
|
|
101
|
+
* how close the app was to settling (3 vs 9000 is a very different
|
|
102
|
+
* diagnosis). Dimension mismatch returns total disagreement
|
|
103
|
+
* defensively; ECP always returns same-resolution captures.
|
|
104
|
+
*/
|
|
105
|
+
export function pngsEqualOutsideMask(
|
|
106
|
+
a: PNG,
|
|
107
|
+
b: PNG,
|
|
108
|
+
masks: MaskRegion[]
|
|
109
|
+
): { equal: boolean; diffCount: number; firstDiffPoint?: { x: number; y: number } } {
|
|
110
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
111
|
+
const diffCount = Math.max(a.width * a.height, b.width * b.height);
|
|
112
|
+
return { equal: false, diffCount };
|
|
113
|
+
}
|
|
114
|
+
const w = a.width;
|
|
115
|
+
const h = a.height;
|
|
116
|
+
const pixelCount = w * h;
|
|
117
|
+
const maskedAt = buildMaskBitmap(masks, w, h);
|
|
118
|
+
|
|
119
|
+
let diffCount = 0;
|
|
120
|
+
let firstDiffPoint: { x: number; y: number } | undefined;
|
|
121
|
+
const ad = a.data;
|
|
122
|
+
const bd = b.data;
|
|
123
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
124
|
+
if (maskedAt[i]) continue;
|
|
125
|
+
const off = i * 4;
|
|
126
|
+
if (
|
|
127
|
+
ad[off] !== bd[off] ||
|
|
128
|
+
ad[off + 1] !== bd[off + 1] ||
|
|
129
|
+
ad[off + 2] !== bd[off + 2] ||
|
|
130
|
+
ad[off + 3] !== bd[off + 3]
|
|
131
|
+
) {
|
|
132
|
+
diffCount += 1;
|
|
133
|
+
if (!firstDiffPoint) {
|
|
134
|
+
firstDiffPoint = { x: i % w, y: Math.floor(i / w) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { equal: diffCount === 0, diffCount, firstDiffPoint };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Unique pixels covered by validated+clamped mask regions
|
|
143
|
+
* (overlaps not double-counted). Denominator for the mismatch ratio.
|
|
144
|
+
*/
|
|
145
|
+
export function countMaskedPixels(masks: MaskRegion[], canvas: { w: number; h: number }): number {
|
|
146
|
+
if (masks.length === 0) return 0;
|
|
147
|
+
const bitmap = buildMaskBitmap(masks, canvas.w, canvas.h);
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (let i = 0; i < bitmap.length; i++) {
|
|
150
|
+
if (bitmap[i]) count += 1;
|
|
151
|
+
}
|
|
152
|
+
return count;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 1-byte-per-pixel mask bitmap; O(sum of region areas) build. */
|
|
156
|
+
export function buildMaskBitmap(masks: MaskRegion[], w: number, h: number): Uint8Array {
|
|
157
|
+
const bitmap = new Uint8Array(w * h);
|
|
158
|
+
for (const m of masks) {
|
|
159
|
+
const x0 = Math.max(0, m.x);
|
|
160
|
+
const y0 = Math.max(0, m.y);
|
|
161
|
+
const x1 = Math.min(w, m.x + m.width);
|
|
162
|
+
const y1 = Math.min(h, m.y + m.height);
|
|
163
|
+
for (let y = y0; y < y1; y++) {
|
|
164
|
+
const rowStart = y * w;
|
|
165
|
+
for (let x = x0; x < x1; x++) {
|
|
166
|
+
bitmap[rowStart + x] = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return bitmap;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Alignment search
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whole-pixel translation applied to the capture: baseline pixel
|
|
179
|
+
* `(x, y)` is scored against capture pixel `(x + dx, y + dy)`.
|
|
180
|
+
*/
|
|
181
|
+
export interface AlignOffset {
|
|
182
|
+
dx: number;
|
|
183
|
+
dy: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Resolved per-axis search radius; both values are >= 0 integers. */
|
|
187
|
+
export interface ResolvedAlignTolerance {
|
|
188
|
+
x: number;
|
|
189
|
+
y: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate and resolve `alignTolerance`. Returns `null` when there is
|
|
194
|
+
* nothing to search (option absent, or both axes zero) so callers can
|
|
195
|
+
* skip the search entirely. Both axes are validated before the
|
|
196
|
+
* no-op check, so `{ y: -1 }` throws rather than being ignored.
|
|
197
|
+
*/
|
|
198
|
+
export function validateAlignTolerance(raw: AlignTolerance | undefined): ResolvedAlignTolerance | null {
|
|
199
|
+
if (!raw) return null;
|
|
200
|
+
const x = validateAlignAxis("x", raw.x ?? 0);
|
|
201
|
+
const y = validateAlignAxis("y", raw.y ?? 0);
|
|
202
|
+
if (x === 0 && y === 0) return null;
|
|
203
|
+
return { x, y };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateAlignAxis(axis: "x" | "y", value: number): number {
|
|
207
|
+
const fail = (reason: "not-an-integer" | "negative" | "over-cap", detail: string): never => {
|
|
208
|
+
throw new ScreenshotAlignToleranceError(`Invalid alignTolerance.${axis}=${value}: ${detail}`, {
|
|
209
|
+
axis,
|
|
210
|
+
value,
|
|
211
|
+
max: ALIGN_TOLERANCE_MAX_PX,
|
|
212
|
+
reason
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
if (!Number.isInteger(value)) {
|
|
216
|
+
fail("not-an-integer", "must be an integer number of pixels.");
|
|
217
|
+
}
|
|
218
|
+
if (value < 0) {
|
|
219
|
+
fail("negative", "must not be negative; the radius applies in both directions.");
|
|
220
|
+
}
|
|
221
|
+
if (value > ALIGN_TOLERANCE_MAX_PX) {
|
|
222
|
+
fail(
|
|
223
|
+
"over-cap",
|
|
224
|
+
`exceeds the ${ALIGN_TOLERANCE_MAX_PX}px cap. A larger window would let the ` +
|
|
225
|
+
`comparator align genuinely different content; re-capture the baseline instead.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every offset in the tolerance box except `(0, 0)`, ordered smallest
|
|
233
|
+
* translation first: by Manhattan distance, then by magnitude on each
|
|
234
|
+
* axis, then by sign. The caller scores all of them and keeps the first
|
|
235
|
+
* minimum, so this order is what makes ties break deterministically and
|
|
236
|
+
* in favour of the least movement. The comparator is total, so the
|
|
237
|
+
* sequence does not depend on the engine's sort stability.
|
|
238
|
+
*/
|
|
239
|
+
export function alignmentCandidates(tol: ResolvedAlignTolerance): AlignOffset[] {
|
|
240
|
+
const out: AlignOffset[] = [];
|
|
241
|
+
for (let dy = -tol.y; dy <= tol.y; dy++) {
|
|
242
|
+
for (let dx = -tol.x; dx <= tol.x; dx++) {
|
|
243
|
+
if (dx === 0 && dy === 0) continue;
|
|
244
|
+
// A zero-tolerance axis yields `-0`, which would reach
|
|
245
|
+
// `ScreenshotMismatchError.context`; normalize to plain 0.
|
|
246
|
+
out.push({ dx: dx === 0 ? 0 : dx, dy: dy === 0 ? 0 : dy });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
out.sort(
|
|
250
|
+
(a, b) =>
|
|
251
|
+
Math.abs(a.dx) + Math.abs(a.dy) - (Math.abs(b.dx) + Math.abs(b.dy)) ||
|
|
252
|
+
Math.abs(a.dy) - Math.abs(b.dy) ||
|
|
253
|
+
Math.abs(a.dx) - Math.abs(b.dx) ||
|
|
254
|
+
a.dy - b.dy ||
|
|
255
|
+
a.dx - b.dx
|
|
256
|
+
);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Pixel rectangle. Structurally a `MaskRegion`, but kept distinct so a
|
|
262
|
+
* comparison window is never mistaken for something being hidden.
|
|
263
|
+
*/
|
|
264
|
+
export interface PixelRect {
|
|
265
|
+
x: number;
|
|
266
|
+
y: number;
|
|
267
|
+
width: number;
|
|
268
|
+
height: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Rectangle of baseline pixels that has a counterpart in the capture
|
|
273
|
+
* once the capture is translated by `offset`. `x`/`y` are the window's
|
|
274
|
+
* top-left in baseline coordinates; the trailing `|dx|` columns and
|
|
275
|
+
* `|dy|` rows have no counterpart and are dropped.
|
|
276
|
+
*
|
|
277
|
+
* Degenerate for an offset at least as large as the canvas, so callers
|
|
278
|
+
* that iterate candidates must skip non-positive windows.
|
|
279
|
+
*/
|
|
280
|
+
export function alignedWindow(offset: AlignOffset, canvas: { w: number; h: number }): PixelRect {
|
|
281
|
+
const x = Math.max(0, -offset.dx);
|
|
282
|
+
const y = Math.max(0, -offset.dy);
|
|
283
|
+
return {
|
|
284
|
+
x,
|
|
285
|
+
y,
|
|
286
|
+
width: Math.min(canvas.w, canvas.w - offset.dx) - x,
|
|
287
|
+
height: Math.min(canvas.h, canvas.h - offset.dy) - y
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Cheap exact-byte mismatch ratio for one candidate offset, used only
|
|
293
|
+
* to RANK offsets. Exact equality is stricter than pixelmatch's YIQ
|
|
294
|
+
* cutoff, so the two metrics can in principle disagree on which offset
|
|
295
|
+
* is best; ranking by this one keeps the search a few milliseconds per
|
|
296
|
+
* candidate instead of a full pixelmatch pass. The verdict is never
|
|
297
|
+
* taken from here — the caller re-runs pixelmatch at the winning offset
|
|
298
|
+
* — so a disagreement can only settle for a marginally worse offset,
|
|
299
|
+
* never turn a real mismatch into a pass.
|
|
300
|
+
*
|
|
301
|
+
* `maskBitmap` is in baseline canvas coordinates (see
|
|
302
|
+
* `buildMaskBitmap`) and is reused across candidates.
|
|
303
|
+
*
|
|
304
|
+
* `ratio` is `Infinity` when the offset leaves nothing to compare
|
|
305
|
+
* (degenerate window, or every remaining pixel masked), so such an
|
|
306
|
+
* offset can never win the search by scoring a vacuous zero.
|
|
307
|
+
*/
|
|
308
|
+
export function scoreAlignOffset(
|
|
309
|
+
baseline: PNG,
|
|
310
|
+
actual: PNG,
|
|
311
|
+
maskBitmap: Uint8Array,
|
|
312
|
+
offset: AlignOffset
|
|
313
|
+
): { diffCount: number; comparable: number; ratio: number } {
|
|
314
|
+
const w = baseline.width;
|
|
315
|
+
const win = alignedWindow(offset, { w, h: baseline.height });
|
|
316
|
+
const bd = baseline.data;
|
|
317
|
+
const ad = actual.data;
|
|
318
|
+
|
|
319
|
+
let diffCount = 0;
|
|
320
|
+
let comparable = 0;
|
|
321
|
+
for (let y = win.y; y < win.y + win.height; y++) {
|
|
322
|
+
const baseRow = y * w;
|
|
323
|
+
const actualRow = (y + offset.dy) * w;
|
|
324
|
+
for (let x = win.x; x < win.x + win.width; x++) {
|
|
325
|
+
if (maskBitmap[baseRow + x]) continue;
|
|
326
|
+
comparable += 1;
|
|
327
|
+
const bo = (baseRow + x) * 4;
|
|
328
|
+
const ao = (actualRow + x + offset.dx) * 4;
|
|
329
|
+
if (
|
|
330
|
+
bd[bo] !== ad[ao] ||
|
|
331
|
+
bd[bo + 1] !== ad[ao + 1] ||
|
|
332
|
+
bd[bo + 2] !== ad[ao + 2] ||
|
|
333
|
+
bd[bo + 3] !== ad[ao + 3]
|
|
334
|
+
) {
|
|
335
|
+
diffCount += 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
diffCount,
|
|
341
|
+
comparable,
|
|
342
|
+
ratio: comparable > 0 ? diffCount / comparable : Number.POSITIVE_INFINITY
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Best-scoring offset in the tolerance box, or `null` when the box is
|
|
348
|
+
* empty or every offset leaves nothing to compare. Ranking only — the
|
|
349
|
+
* caller must still run the authoritative comparison at the returned
|
|
350
|
+
* offset, and may assume the offset yields a non-degenerate window.
|
|
351
|
+
*
|
|
352
|
+
* Starting `bestRatio` at `Infinity` is what excludes the degenerate
|
|
353
|
+
* offsets that `scoreAlignOffset` scores as `Infinity`; a strict `<`
|
|
354
|
+
* keeps the first (smallest) offset on ties.
|
|
355
|
+
*/
|
|
356
|
+
export function findBestAlignOffset(
|
|
357
|
+
baseline: PNG,
|
|
358
|
+
actual: PNG,
|
|
359
|
+
masks: MaskRegion[],
|
|
360
|
+
tol: ResolvedAlignTolerance
|
|
361
|
+
): AlignOffset | null {
|
|
362
|
+
const maskBitmap = buildMaskBitmap(masks, baseline.width, baseline.height);
|
|
363
|
+
let best: AlignOffset | null = null;
|
|
364
|
+
let bestRatio = Number.POSITIVE_INFINITY;
|
|
365
|
+
for (const candidate of alignmentCandidates(tol)) {
|
|
366
|
+
const { ratio } = scoreAlignOffset(baseline, actual, maskBitmap, candidate);
|
|
367
|
+
if (ratio < bestRatio) {
|
|
368
|
+
bestRatio = ratio;
|
|
369
|
+
best = candidate;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return best;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Crop both images to `alignedWindow(offset)` — the capture read at
|
|
377
|
+
* its translated position — and zero the masked pixels in both, ready
|
|
378
|
+
* for `pixelmatch`. Masks are authored in baseline canvas coordinates
|
|
379
|
+
* and so land on the intended content in the baseline and on its
|
|
380
|
+
* translated counterpart in the capture.
|
|
381
|
+
*
|
|
382
|
+
* `offset` must yield a non-degenerate `alignedWindow` — the identity
|
|
383
|
+
* offset always does, and `findBestAlignOffset` never returns one that
|
|
384
|
+
* does not.
|
|
385
|
+
*/
|
|
386
|
+
export function buildAlignedPair(
|
|
387
|
+
baseline: PNG,
|
|
388
|
+
actual: PNG,
|
|
389
|
+
masks: MaskRegion[],
|
|
390
|
+
offset: AlignOffset
|
|
391
|
+
): { baseline: PNG; actual: PNG; window: PixelRect; maskedPixels: number } {
|
|
392
|
+
const w = baseline.width;
|
|
393
|
+
const win = alignedWindow(offset, { w, h: baseline.height });
|
|
394
|
+
const croppedBaseline = new PNG({ width: win.width, height: win.height });
|
|
395
|
+
const croppedActual = new PNG({ width: win.width, height: win.height });
|
|
396
|
+
|
|
397
|
+
for (let row = 0; row < win.height; row++) {
|
|
398
|
+
const destStart = row * win.width * 4;
|
|
399
|
+
const baseStart = ((win.y + row) * w + win.x) * 4;
|
|
400
|
+
const actualStart = ((win.y + row + offset.dy) * w + win.x + offset.dx) * 4;
|
|
401
|
+
baseline.data.copy(croppedBaseline.data, destStart, baseStart, baseStart + win.width * 4);
|
|
402
|
+
actual.data.copy(croppedActual.data, destStart, actualStart, actualStart + win.width * 4);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Translate masks from canvas space into the window's frame; both
|
|
406
|
+
// crops already carry their own offset, so one translation serves
|
|
407
|
+
// both. Zero-area intersections are dropped rather than validated
|
|
408
|
+
// again — the regions were validated against the full canvas.
|
|
409
|
+
const windowMasks: MaskRegion[] = [];
|
|
410
|
+
for (const m of masks) {
|
|
411
|
+
const x0 = Math.max(0, m.x - win.x);
|
|
412
|
+
const y0 = Math.max(0, m.y - win.y);
|
|
413
|
+
const x1 = Math.min(win.width, m.x + m.width - win.x);
|
|
414
|
+
const y1 = Math.min(win.height, m.y + m.height - win.y);
|
|
415
|
+
if (x1 <= x0 || y1 <= y0) continue;
|
|
416
|
+
windowMasks.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
417
|
+
}
|
|
418
|
+
applyMaskInPlace(croppedBaseline, windowMasks, 0x00000000);
|
|
419
|
+
applyMaskInPlace(croppedActual, windowMasks, 0x00000000);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
baseline: croppedBaseline,
|
|
423
|
+
actual: croppedActual,
|
|
424
|
+
window: win,
|
|
425
|
+
maskedPixels: countMaskedPixels(windowMasks, { w: win.width, h: win.height })
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Lift a window-sized diff back onto a full canvas at the window's
|
|
431
|
+
* baseline coordinates, so diff-PNG coordinates always line up with
|
|
432
|
+
* the baseline. Rows/columns outside the window had no counterpart to
|
|
433
|
+
* compare and stay transparent.
|
|
434
|
+
*/
|
|
435
|
+
export function placeDiffOnCanvas(windowDiff: PNG, canvas: { w: number; h: number }, window: PixelRect): PNG {
|
|
436
|
+
if (window.x === 0 && window.y === 0 && windowDiff.width === canvas.w && windowDiff.height === canvas.h) {
|
|
437
|
+
return windowDiff;
|
|
438
|
+
}
|
|
439
|
+
const full = new PNG({ width: canvas.w, height: canvas.h });
|
|
440
|
+
full.data.fill(0);
|
|
441
|
+
for (let row = 0; row < window.height; row++) {
|
|
442
|
+
const srcStart = row * window.width * 4;
|
|
443
|
+
const destStart = ((window.y + row) * canvas.w + window.x) * 4;
|
|
444
|
+
windowDiff.data.copy(full.data, destStart, srcStart, srcStart + window.width * 4);
|
|
445
|
+
}
|
|
446
|
+
return full;
|
|
447
|
+
}
|