@roku-sdk/device-test-client 0.0.0-stage → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/BREAKING_CHANGES.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -2
  4. package/docs/baselines.md +221 -0
  5. package/docs/configuration.md +258 -0
  6. package/docs/dom-assertions.md +264 -0
  7. package/docs/errors.md +544 -0
  8. package/docs/examples/deep-link-cold-start.md +85 -0
  9. package/docs/examples/focus-navigation.md +81 -0
  10. package/docs/examples/log-driven-assertion.md +99 -0
  11. package/docs/examples/precondition-skips.md +150 -0
  12. package/docs/examples/screenshot-regression.md +103 -0
  13. package/docs/examples/smoke-test.md +69 -0
  14. package/docs/getting-started.md +195 -0
  15. package/docs/jest-integration.md +331 -0
  16. package/docs/log-assertions.md +349 -0
  17. package/docs/preconditions.md +317 -0
  18. package/docs/rkclient.md +657 -0
  19. package/docs/screenshot-assertions.md +316 -0
  20. package/package.json +81 -3
  21. package/src/assertions/assertions.ts +661 -0
  22. package/src/assertions/baseline-env-guard.ts +43 -0
  23. package/src/assertions/baselines.ts +178 -0
  24. package/src/assertions/screenshot-artifacts.ts +39 -0
  25. package/src/assertions/screenshot-diff.ts +447 -0
  26. package/src/client/build-cache.ts +292 -0
  27. package/src/client/client.ts +1964 -0
  28. package/src/client/deploy.ts +407 -0
  29. package/src/client/ecp-queue.ts +112 -0
  30. package/src/client/ecp-retry.ts +94 -0
  31. package/src/client/factory.ts +170 -0
  32. package/src/client/health.ts +345 -0
  33. package/src/client/parsers.ts +162 -0
  34. package/src/client/server-url.ts +137 -0
  35. package/src/constants.ts +520 -0
  36. package/src/device/device-class.ts +57 -0
  37. package/src/device/device-env-sync.ts +58 -0
  38. package/src/device/device-resolver-sync.ts +43 -0
  39. package/src/device/device-resolver.ts +141 -0
  40. package/src/device/lease-provisioning.ts +54 -0
  41. package/src/errors.ts +774 -0
  42. package/src/index.ts +181 -0
  43. package/src/internal/constants.ts +3 -0
  44. package/src/jest/artifacts.ts +87 -0
  45. package/src/jest/device-test-environment.ts +420 -0
  46. package/src/jest/failure-artifacts.ts +169 -0
  47. package/src/jest/global-setup.ts +137 -0
  48. package/src/jest/global-teardown.ts +132 -0
  49. package/src/jest/install-hooks.ts +490 -0
  50. package/src/jest/jest-setup.ts +53 -0
  51. package/src/jest/preconditions.ts +239 -0
  52. package/src/jest/telemetry.ts +331 -0
  53. package/src/logs/device-log-sink.ts +69 -0
  54. package/src/logs/log-buffer.ts +278 -0
  55. package/src/logs/log-session.ts +436 -0
  56. package/src/logs/log-stream-client.ts +296 -0
  57. package/src/paths.ts +65 -0
  58. package/src/types.ts +472 -0
  59. package/support/constants.ts +17 -0
  60. package/support/deploy.ts +27 -0
  61. package/support/paths.ts +13 -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
+ }