@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/docs/baselines.md +221 -0
  4. package/docs/configuration.md +258 -0
  5. package/docs/dom-assertions.md +264 -0
  6. package/docs/errors.md +525 -0
  7. package/docs/examples/deep-link-cold-start.md +85 -0
  8. package/docs/examples/focus-navigation.md +81 -0
  9. package/docs/examples/log-driven-assertion.md +99 -0
  10. package/docs/examples/precondition-skips.md +150 -0
  11. package/docs/examples/screenshot-regression.md +103 -0
  12. package/docs/examples/smoke-test.md +69 -0
  13. package/docs/getting-started.md +195 -0
  14. package/docs/jest-integration.md +331 -0
  15. package/docs/log-assertions.md +314 -0
  16. package/docs/preconditions.md +317 -0
  17. package/docs/rkclient.md +655 -0
  18. package/docs/screenshot-assertions.md +316 -0
  19. package/internal/constants.ts +54 -0
  20. package/internal/deploy.ts +60 -0
  21. package/internal/paths.ts +15 -0
  22. package/package.json +80 -3
  23. package/src/assertions/assertions.ts +661 -0
  24. package/src/assertions/baseline-env-guard.ts +43 -0
  25. package/src/assertions/baselines.ts +178 -0
  26. package/src/assertions/screenshot-artifacts.ts +39 -0
  27. package/src/assertions/screenshot-diff.ts +447 -0
  28. package/src/client/build-cache.ts +292 -0
  29. package/src/client/client.ts +1656 -0
  30. package/src/client/deploy.ts +402 -0
  31. package/src/client/ecp-queue.ts +112 -0
  32. package/src/client/ecp-retry.ts +94 -0
  33. package/src/client/factory.ts +147 -0
  34. package/src/client/health.ts +214 -0
  35. package/src/client/parsers.ts +155 -0
  36. package/src/client/server-url.ts +137 -0
  37. package/src/constants.ts +471 -0
  38. package/src/device/device-class.ts +57 -0
  39. package/src/device/device-env-sync.ts +58 -0
  40. package/src/device/device-resolver-sync.ts +43 -0
  41. package/src/device/device-resolver.ts +141 -0
  42. package/src/device/lease-provisioning.ts +54 -0
  43. package/src/errors.ts +773 -0
  44. package/src/index.ts +186 -0
  45. package/src/internal/constants.ts +3 -0
  46. package/src/jest/artifacts.ts +87 -0
  47. package/src/jest/device-test-environment.ts +420 -0
  48. package/src/jest/failure-artifacts.ts +169 -0
  49. package/src/jest/global-setup.ts +133 -0
  50. package/src/jest/global-teardown.ts +132 -0
  51. package/src/jest/install-hooks.ts +488 -0
  52. package/src/jest/jest-setup.ts +53 -0
  53. package/src/jest/preconditions.ts +239 -0
  54. package/src/jest/telemetry.ts +331 -0
  55. package/src/logs/device-log-sink.ts +69 -0
  56. package/src/logs/log-buffer.ts +265 -0
  57. package/src/logs/log-session.ts +374 -0
  58. package/src/logs/log-stream-client.ts +296 -0
  59. package/src/paths.ts +65 -0
  60. package/src/types.ts +443 -0
@@ -0,0 +1,661 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import pixelmatch from "pixelmatch";
7
+ import { PNG } from "pngjs";
8
+
9
+ import {
10
+ DEFAULT_SCREENSHOT_THRESHOLD,
11
+ ENV_VAR,
12
+ PIXELMATCH_COLOR_THRESHOLD,
13
+ TREE_DIFF_SNIPPET_MAX_CHARS,
14
+ TREE_SUMMARY_MAX_CHARS,
15
+ TREE_SUMMARY_MAX_DEPTH
16
+ } from "../constants";
17
+ import {
18
+ BaselineMissingError,
19
+ FocusedNodeMismatchError,
20
+ NodeNotFoundError,
21
+ ScreenshotBaselineMissingError,
22
+ ScreenshotDimensionsMismatchError,
23
+ ScreenshotMismatchError,
24
+ TreeMismatchError
25
+ } from "../errors";
26
+ import type { AppUiNode, NodePredicate, ScreenshotMatchOptions, TreeMatchOptions } from "../types";
27
+ import { assertBaselineUpdatesAllowed, updateBaselinesRequested } from "./baseline-env-guard";
28
+ import { atomicWriteFileSync, canvasSizeToResolution, resolveBaselinePath } from "./baselines";
29
+ import { getScreenshotDiffRoot } from "./screenshot-artifacts";
30
+ import type { AlignOffset, PixelRect } from "./screenshot-diff";
31
+ import {
32
+ buildAlignedPair,
33
+ findBestAlignOffset,
34
+ placeDiffOnCanvas,
35
+ validateAlignTolerance,
36
+ validateMasks
37
+ } from "./screenshot-diff";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Predicate matching
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * True iff `node` satisfies every populated field of `predicate`.
45
+ * `tag` exact, `attr` partial (per `(k, v)`), `focused` exact.
46
+ *
47
+ * `id` aliases to `attrs.name`: ECP serializes SceneGraph `id` as
48
+ * the XML `name` attribute, but JSX/XML source uses `id` so we accept
49
+ * it transparently (matching `name` directly still works).
50
+ *
51
+ * Empty predicate `{}` matches every node.
52
+ */
53
+ export function matchesPredicate(node: AppUiNode, predicate: NodePredicate): boolean {
54
+ if (predicate.tag !== undefined && node.tag !== predicate.tag) return false;
55
+ if (predicate.focused !== undefined && node.focused !== predicate.focused) return false;
56
+ if (predicate.attr) {
57
+ for (const key of Object.keys(predicate.attr)) {
58
+ const actual = key === "id" ? (node.attrs.id ?? node.attrs.name) : node.attrs[key];
59
+ if (actual !== predicate.attr[key]) return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Tree traversal
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Non-throwing variant of `findNode`. Internal use only — not
71
+ * re-exported from `src/index.ts`; external callers use `findNode`
72
+ * (throws with context) or `findAllNodes`.
73
+ */
74
+ export function tryFindNode(tree: AppUiNode, predicate: NodePredicate): AppUiNode | null {
75
+ const stack: AppUiNode[] = [tree];
76
+ while (stack.length > 0) {
77
+ const node = stack.pop()!;
78
+ if (matchesPredicate(node, predicate)) return node;
79
+ // Reverse-push so document-order "first match wins" holds.
80
+ for (let i = node.children.length - 1; i >= 0; i--) {
81
+ stack.push(node.children[i]!);
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** First node matching `predicate`. Throws `NodeNotFoundError` if none. */
88
+ export function findNode(tree: AppUiNode, predicate: NodePredicate): AppUiNode {
89
+ const found = tryFindNode(tree, predicate);
90
+ if (found) return found;
91
+ const summary = summarizeTree(tree);
92
+ throw new NodeNotFoundError(
93
+ `No node matched predicate ${JSON.stringify(predicate)}. ` +
94
+ `Tree summary (first ${TREE_SUMMARY_MAX_DEPTH} levels, truncated):\n${summary}`,
95
+ predicate,
96
+ summary
97
+ );
98
+ }
99
+
100
+ /** All nodes matching `predicate`, document order. Empty array if none. */
101
+ export function findAllNodes(tree: AppUiNode, predicate: NodePredicate): AppUiNode[] {
102
+ const out: AppUiNode[] = [];
103
+ const stack: AppUiNode[] = [tree];
104
+ while (stack.length > 0) {
105
+ const node = stack.pop()!;
106
+ if (matchesPredicate(node, predicate)) out.push(node);
107
+ for (let i = node.children.length - 1; i >= 0; i--) {
108
+ stack.push(node.children[i]!);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Deepest focused node in the tree. SceneGraph propagates `focused`
116
+ * up the ancestor chain so a naive DFS returns the root-most match
117
+ * (typically the outer `<screen>`); we descend the focus chain to the
118
+ * leaf to match tester intent. Throws `NodeNotFoundError` when no
119
+ * node is focused (a legitimate transient state worth distinguishing
120
+ * from a generic no-match).
121
+ */
122
+ export function findFocusedNode(tree: AppUiNode): AppUiNode {
123
+ const entry = tryFindNode(tree, { focused: true });
124
+ if (!entry) {
125
+ const summary = summarizeTree(tree);
126
+ throw new NodeNotFoundError(`No focused node in tree. Tree summary:\n${summary}`, { focused: true }, summary);
127
+ }
128
+ let node = entry;
129
+ while (true) {
130
+ const focusedChild = node.children.find(c => c.focused);
131
+ if (!focusedChild) return node;
132
+ node = focusedChild;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Asserts the focused node matches `predicate`. Throws
138
+ * `NodeNotFoundError` if nothing is focused, or `FocusedNodeMismatchError`
139
+ * on predicate mismatch.
140
+ */
141
+ export function expectFocused(tree: AppUiNode, predicate: NodePredicate): void {
142
+ const focused = findFocusedNode(tree);
143
+ if (matchesPredicate(focused, predicate)) return;
144
+ const summary = summarizeNode(focused);
145
+ throw new FocusedNodeMismatchError(
146
+ `Focused node does not match predicate ${JSON.stringify(predicate)}. ` + `Focused node: ${summary}`,
147
+ predicate,
148
+ summary
149
+ );
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Tree summarization (used inside error messages)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Render `tree` as indented pseudo-XML, truncated at `maxDepth`
158
+ * (children beyond → `[N children]`) and `maxChars`. Diagnostic
159
+ * format, not a round-trippable serialization.
160
+ */
161
+ export function summarizeTree(
162
+ tree: AppUiNode,
163
+ maxDepth: number = TREE_SUMMARY_MAX_DEPTH,
164
+ maxChars: number = TREE_SUMMARY_MAX_CHARS
165
+ ): string {
166
+ const lines: string[] = [];
167
+ function walk(node: AppUiNode, depth: number): void {
168
+ const indent = " ".repeat(depth);
169
+ const attrBits = Object.entries(node.attrs)
170
+ .slice(0, 4)
171
+ .map(([k, v]) => `${k}="${truncate(v, 40)}"`)
172
+ .join(" ");
173
+ const head = attrBits ? `<${node.tag} ${attrBits}>` : `<${node.tag}>`;
174
+ lines.push(`${indent}${head}`);
175
+ if (depth >= maxDepth && node.children.length > 0) {
176
+ lines.push(`${indent} [${node.children.length} children]`);
177
+ return;
178
+ }
179
+ for (const child of node.children) walk(child, depth + 1);
180
+ }
181
+ walk(tree, 0);
182
+ const out = lines.join("\n");
183
+ if (out.length <= maxChars) return out;
184
+ const cut = out.slice(0, maxChars);
185
+ return `${cut}\n… (${out.length - maxChars} more chars)`;
186
+ }
187
+
188
+ /** One-line projection of a node: tag + up to 4 attrs. Used in focus-mismatch errors. */
189
+ function summarizeNode(node: AppUiNode): string {
190
+ const attrBits = Object.entries(node.attrs)
191
+ .slice(0, 4)
192
+ .map(([k, v]) => `${k}="${truncate(v, 40)}"`)
193
+ .join(" ");
194
+ return attrBits ? `<${node.tag} ${attrBits}>` : `<${node.tag}>`;
195
+ }
196
+
197
+ function truncate(s: string, n: number): string {
198
+ return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // expectTree — baseline comparison
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export interface TreeAssertion {
206
+ toMatchBaseline(baselinePath: string, opts?: TreeMatchOptions): Promise<void>;
207
+ }
208
+
209
+ export interface ScreenshotAssertion {
210
+ toMatchBaseline(baselinePath: string, opts?: ScreenshotMatchOptions): Promise<void>;
211
+ }
212
+
213
+ /**
214
+ * Baseline projection of an `AppUiNode`. `bounds` and `focused` are
215
+ * dropped: they're derivable from `attrs` and a 1px layout shift
216
+ * would otherwise rewrite every node's `bounds`.
217
+ */
218
+ interface SerializedNode {
219
+ tag: string;
220
+ attrs: Record<string, string>;
221
+ children: SerializedNode[];
222
+ }
223
+
224
+ /**
225
+ * Device-emitted attributes that are never stable across deploys and
226
+ * must always be stripped from baselines, independent of the caller's
227
+ * `exclude`. `rcid` is SceneGraph's render-component id: a per-session
228
+ * counter that is `0` on a clean first deploy but increments whenever a
229
+ * node is re-created — e.g. when the deploy-guard retries a flaky
230
+ * install. Pinning it would make every tree baseline fail spuriously
231
+ * after a single deploy retry, so it is excluded by default (the same
232
+ * way `bounds`/`focused` are dropped by living outside `attrs`).
233
+ */
234
+ const VOLATILE_BASELINE_ATTRS: readonly string[] = ["rcid"];
235
+
236
+ /**
237
+ * Serialize `node` to a stable baseline shape: attrs sorted, excludes
238
+ * stripped recursively, children kept in DOM order (semantically
239
+ * meaningful for focus/tab order and stacking). Volatile device attrs
240
+ * (`VOLATILE_BASELINE_ATTRS`) are always stripped in addition to the
241
+ * caller-supplied `exclude`. A node whose tag is in `excludeChildrenOf`
242
+ * is kept but serialized as a leaf, hiding internals the app does not
243
+ * own (native chrome that is restructured between firmware builds).
244
+ */
245
+ function serializeForBaseline(
246
+ node: AppUiNode,
247
+ exclude: readonly string[],
248
+ excludeChildrenOf: readonly string[]
249
+ ): SerializedNode {
250
+ const excludeSet = new Set<string>([...VOLATILE_BASELINE_ATTRS, ...exclude]);
251
+ const leafTags = new Set<string>(excludeChildrenOf);
252
+ function walk(n: AppUiNode): SerializedNode {
253
+ const attrs: Record<string, string> = {};
254
+ for (const key of Object.keys(n.attrs).sort()) {
255
+ if (excludeSet.has(key)) continue;
256
+ attrs[key] = n.attrs[key]!;
257
+ }
258
+ return {
259
+ tag: n.tag,
260
+ attrs,
261
+ children: leafTags.has(n.tag) ? [] : n.children.map(walk)
262
+ };
263
+ }
264
+ return walk(node);
265
+ }
266
+
267
+ function inferTreeResolution(node: AppUiNode): ReturnType<typeof canvasSizeToResolution> {
268
+ const stack: AppUiNode[] = [node];
269
+ while (stack.length > 0) {
270
+ const current = stack.pop()!;
271
+ const resolution = canvasSizeToResolution(current.bounds.width, current.bounds.height);
272
+ if (resolution) {
273
+ return resolution;
274
+ }
275
+ for (let i = 0; i < current.children.length; i++) {
276
+ stack.push(current.children[i]!);
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * DOM baseline assertion. Reads/writes under
284
+ * `<baselineRoot>/{hd|fhd}/<baselinePath>`. Full app trees route by
285
+ * observed canvas bounds; subtrees fall back to the active client's
286
+ * `videoMode` or `DEVICE_TEST_RESOLUTION`.
287
+ */
288
+ export function expectTree(node: AppUiNode): TreeAssertion {
289
+ return {
290
+ async toMatchBaseline(baselinePath: string, opts?: TreeMatchOptions): Promise<void> {
291
+ const serialized = serializeForBaseline(node, opts?.exclude ?? [], opts?.excludeChildrenOf ?? []);
292
+ const json = stableJsonStringify(serialized);
293
+
294
+ const inferredResolution = inferTreeResolution(node);
295
+ const { absolutePath, resolution, videoMode } = resolveBaselinePath(
296
+ baselinePath,
297
+ inferredResolution ? { resolution: inferredResolution } : undefined
298
+ );
299
+
300
+ const updating = updateBaselinesRequested();
301
+
302
+ if (!existsSync(absolutePath)) {
303
+ if (updating) {
304
+ assertBaselineUpdatesAllowed();
305
+ mkdirSync(dirname(absolutePath), { recursive: true });
306
+ atomicWriteFileSync(absolutePath, `${json}\n`);
307
+ return;
308
+ }
309
+ throw new BaselineMissingError(
310
+ `Baseline file missing: ${absolutePath}. ` +
311
+ `Re-run with ${ENV_VAR.UPDATE_BASELINES}=1 to create it ` +
312
+ `(resolution=${resolution}${videoMode ? `, videoMode=${videoMode}` : ""}).`,
313
+ absolutePath,
314
+ resolution,
315
+ videoMode
316
+ );
317
+ }
318
+
319
+ const existing = readFileSync(absolutePath, "utf8");
320
+ // Re-stringify to canonicalise hand-edits (trailing
321
+ // newline / attr-order drift).
322
+ let parsedExisting: unknown;
323
+ try {
324
+ parsedExisting = JSON.parse(existing);
325
+ } catch (err) {
326
+ const errMsg = err instanceof Error ? err.message : String(err);
327
+ throw new TreeMismatchError(
328
+ `Baseline at ${absolutePath} is not valid JSON: ${errMsg}. ` +
329
+ `Delete it and re-run with ${ENV_VAR.UPDATE_BASELINES}=1 to regenerate.`,
330
+ {
331
+ baselinePath: absolutePath,
332
+ firstDiff: "$",
333
+ expectedSnippet: truncate(existing, TREE_DIFF_SNIPPET_MAX_CHARS),
334
+ actualSnippet: truncate(json, TREE_DIFF_SNIPPET_MAX_CHARS)
335
+ }
336
+ );
337
+ }
338
+ const expectedJson = stableJsonStringify(parsedExisting);
339
+
340
+ if (expectedJson === json) return;
341
+
342
+ if (updating) {
343
+ assertBaselineUpdatesAllowed();
344
+ atomicWriteFileSync(absolutePath, `${json}\n`);
345
+ return;
346
+ }
347
+
348
+ const diff = firstJsonDiff(parsedExisting, serialized);
349
+ throw new TreeMismatchError(
350
+ `Tree does not match baseline ${absolutePath} at ${diff.path}.\n` +
351
+ `expected: ${diff.expectedSnippet}\n` +
352
+ `actual: ${diff.actualSnippet}\n` +
353
+ `Run with ${ENV_VAR.UPDATE_BASELINES}=1 after reviewing to accept the change.`,
354
+ {
355
+ baselinePath: absolutePath,
356
+ firstDiff: diff.path,
357
+ expectedSnippet: diff.expectedSnippet,
358
+ actualSnippet: diff.actualSnippet
359
+ }
360
+ );
361
+ }
362
+ };
363
+ }
364
+
365
+ /** Identity translation: the first (and usually only) attempt. */
366
+ const NO_ALIGN_OFFSET: AlignOffset = { dx: 0, dy: 0 };
367
+
368
+ /**
369
+ * PNG baseline assertion. Compares `png` against
370
+ * `<baselineRoot>/{hd|fhd}/<baselinePath>` via `pixelmatch`.
371
+ *
372
+ * Errors:
373
+ * - Missing baseline + no `UPDATE_BASELINES=1` → `ScreenshotBaselineMissingError`.
374
+ * - Dimension mismatch → `ScreenshotDimensionsMismatchError` (no diff written).
375
+ * - Malformed `opts.alignTolerance` → `ScreenshotAlignToleranceError`.
376
+ * - Mismatch ratio > threshold → `ScreenshotMismatchError` with
377
+ * diff PNG written under `getScreenshotDiffRoot()`.
378
+ *
379
+ * Masks (`opts.mask`) zero pixels in both images and are excluded
380
+ * from the ratio denominator. `opts.threshold` is the aggregate
381
+ * mismatch ratio; the per-pixel YIQ cutoff is a fixed internal
382
+ * constant (`PIXELMATCH_COLOR_THRESHOLD`) callers can't shift.
383
+ *
384
+ * `opts.alignTolerance` lets the capture be translated by a few pixels
385
+ * to absorb device-dependent re-centering of firmware-drawn surfaces.
386
+ * It is evaluated only after the untranslated comparison has already
387
+ * exceeded `threshold`, so it can rescue a failing assertion but never
388
+ * relax a passing one, and adds no work on the happy path.
389
+ */
390
+ export function expectScreenshot(png: Buffer): ScreenshotAssertion {
391
+ return {
392
+ async toMatchBaseline(baselinePath: string, opts?: ScreenshotMatchOptions): Promise<void> {
393
+ const threshold = opts?.threshold ?? DEFAULT_SCREENSHOT_THRESHOLD;
394
+ const rawMasks = opts?.mask ?? [];
395
+ const actualPng = PNG.sync.read(png);
396
+ const inferredResolution = canvasSizeToResolution(actualPng.width, actualPng.height);
397
+ const { absolutePath, resolution } = resolveBaselinePath(
398
+ baselinePath,
399
+ inferredResolution ? { resolution: inferredResolution } : undefined
400
+ );
401
+ const updating = updateBaselinesRequested();
402
+
403
+ // Missing baseline branch.
404
+ if (!existsSync(absolutePath)) {
405
+ if (updating) {
406
+ assertBaselineUpdatesAllowed();
407
+ mkdirSync(dirname(absolutePath), { recursive: true });
408
+ atomicWriteFileSync(absolutePath, png);
409
+ return;
410
+ }
411
+ throw new ScreenshotBaselineMissingError(
412
+ `Baseline PNG missing: ${absolutePath}. ` +
413
+ `Re-run with ${ENV_VAR.UPDATE_BASELINES}=1 to create it ` +
414
+ `(resolution=${resolution}).`,
415
+ absolutePath,
416
+ resolution
417
+ );
418
+ }
419
+
420
+ // Parse both PNGs. pngjs throws on invalid bytes — let that
421
+ // bubble up so corrupted baselines surface as a distinct
422
+ // parse error (not a fake "mismatch").
423
+ const baselineBuf = readFileSync(absolutePath);
424
+ const baselinePng = PNG.sync.read(baselineBuf);
425
+
426
+ // Dimension mismatch: pixelmatch REQUIRES equal sizes, and
427
+ // a diff PNG at mismatched sizes would be meaningless. We
428
+ // raise a distinct error (no diff written) so the reporter
429
+ // can tell "the baseline is stale / device resolution
430
+ // changed" apart from "a few pixels drifted".
431
+ if (baselinePng.width !== actualPng.width || baselinePng.height !== actualPng.height) {
432
+ if (updating) {
433
+ assertBaselineUpdatesAllowed();
434
+ atomicWriteFileSync(absolutePath, png);
435
+ return;
436
+ }
437
+ throw new ScreenshotDimensionsMismatchError(
438
+ `Screenshot dimensions differ from baseline ${absolutePath}: ` +
439
+ `baseline ${baselinePng.width}x${baselinePng.height}, ` +
440
+ `actual ${actualPng.width}x${actualPng.height}. ` +
441
+ `No diff PNG written — pixelmatch cannot compare at different sizes. ` +
442
+ `Re-run with ${ENV_VAR.UPDATE_BASELINES}=1 after verifying the resolution change is intentional.`,
443
+ {
444
+ baselinePath: absolutePath,
445
+ resolution,
446
+ baseline: { w: baselinePng.width, h: baselinePng.height },
447
+ actual: { w: actualPng.width, h: actualPng.height }
448
+ }
449
+ );
450
+ }
451
+
452
+ const w = baselinePng.width;
453
+ const h = baselinePng.height;
454
+ // Malformed masks throw ScreenshotMaskError (caller bug);
455
+ // partially-OOB regions clamp to canvas.
456
+ const masks = validateMasks(rawMasks, { w, h });
457
+ // Validated up front, not lazily in the failure path, so a
458
+ // malformed tolerance is loud on a passing run too.
459
+ const alignTolerance = validateAlignTolerance(opts?.alignTolerance);
460
+ const totalPixels = w * h;
461
+
462
+ // Compare at one candidate translation. Masking happens on
463
+ // crops rather than in place, so every attempt sees pristine
464
+ // pixels — pre-zeroing the originals would make a translated
465
+ // attempt read zeroed capture pixels against real baseline
466
+ // pixels along every mask edge.
467
+ const compareAt = (
468
+ offset: AlignOffset
469
+ ): { mismatchedPixels: number; ratio: number; diff: PNG; window: PixelRect } => {
470
+ const pair = buildAlignedPair(baselinePng, actualPng, masks, offset);
471
+ const { width, height } = pair.window;
472
+ const diff = new PNG({ width, height });
473
+ // `PIXELMATCH_COLOR_THRESHOLD` is a fixed per-pixel YIQ
474
+ // cutoff; caller-facing `threshold` only governs the
475
+ // aggregate ratio below.
476
+ const mismatchedPixels = pixelmatch(pair.baseline.data, pair.actual.data, diff.data, width, height, {
477
+ threshold: PIXELMATCH_COLOR_THRESHOLD,
478
+ includeAA: false
479
+ });
480
+ // Denominator = comparable pixels only: the overlap
481
+ // window minus its masked pixels. Floor at 1 to avoid
482
+ // div-by-zero on a fully-masked canvas.
483
+ const denominator = Math.max(1, width * height - pair.maskedPixels);
484
+ return { mismatchedPixels, ratio: mismatchedPixels / denominator, diff, window: pair.window };
485
+ };
486
+
487
+ let best = compareAt(NO_ALIGN_OFFSET);
488
+ const unalignedRatio = best.ratio;
489
+ let alignment: AlignOffset | undefined;
490
+
491
+ // Consulted only once the untranslated comparison has
492
+ // failed, so `alignTolerance` can rescue a failing assertion
493
+ // but never loosen a passing one — and costs nothing on the
494
+ // happy path.
495
+ if (best.ratio > threshold && alignTolerance) {
496
+ const candidate = findBestAlignOffset(baselinePng, actualPng, masks, alignTolerance);
497
+ if (candidate) {
498
+ const shifted = compareAt(candidate);
499
+ if (shifted.ratio < best.ratio) {
500
+ best = shifted;
501
+ alignment = candidate;
502
+ }
503
+ }
504
+ }
505
+
506
+ const { mismatchedPixels, ratio } = best;
507
+ if (ratio <= threshold) return;
508
+
509
+ if (updating) {
510
+ assertBaselineUpdatesAllowed();
511
+ atomicWriteFileSync(absolutePath, png);
512
+ return;
513
+ }
514
+
515
+ // Persist the diff PNG before throwing so CI reporters
516
+ // can attach the artifact even if the error is swallowed.
517
+ const diffRoot = getScreenshotDiffRoot();
518
+ const diffFilename = buildDiffFilename(baselinePath);
519
+ const diffPath = join(diffRoot, diffFilename);
520
+ mkdirSync(diffRoot, { recursive: true });
521
+ const diffBuf = PNG.sync.write(placeDiffOnCanvas(best.diff, { w, h }, best.window));
522
+ atomicWriteFileSync(diffPath, diffBuf);
523
+
524
+ // A non-zero alignment that still failed means the surface
525
+ // both moved and changed; reporting the untranslated ratio
526
+ // too separates "drifted past the tolerance" from "genuinely
527
+ // different content".
528
+ const alignmentNote = alignment
529
+ ? ` Best alignment dx=${alignment.dx}, dy=${alignment.dy} ` +
530
+ `(untranslated ratio was ${unalignedRatio.toFixed(5)}).`
531
+ : "";
532
+
533
+ throw new ScreenshotMismatchError(
534
+ `Screenshot does not match baseline ${absolutePath}: ` +
535
+ `${mismatchedPixels}/${totalPixels} pixels differ ` +
536
+ `(unmasked ratio ${ratio.toFixed(5)} > threshold ${threshold}).` +
537
+ `${alignmentNote} ` +
538
+ `Diff: ${diffPath}. ` +
539
+ `Run with ${ENV_VAR.UPDATE_BASELINES}=1 after reviewing to accept the change.`,
540
+ {
541
+ baselinePath: absolutePath,
542
+ resolution,
543
+ actual: { w, h },
544
+ mismatchedPixels,
545
+ totalPixels,
546
+ ratio,
547
+ threshold,
548
+ diffPath,
549
+ ...(alignment ? { alignment } : {})
550
+ }
551
+ );
552
+ }
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Flatten a relative baseline path into a single diff filename:
558
+ * `/` → `--`, `.diff` infix before extension. Extensionless paths
559
+ * get `.diff.png` appended.
560
+ */
561
+ function buildDiffFilename(relBaselinePath: string): string {
562
+ const flattened = relBaselinePath.replace(/^\/+/, "").replace(/\//g, "--");
563
+ const dotIdx = flattened.lastIndexOf(".");
564
+ if (dotIdx <= 0) return `${flattened}.diff.png`;
565
+ const stem = flattened.slice(0, dotIdx);
566
+ const ext = flattened.slice(dotIdx + 1);
567
+ return `${stem}.diff.${ext}`;
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Stable JSON serialization + minimal diff
572
+ // ---------------------------------------------------------------------------
573
+
574
+ /**
575
+ * Deterministic JSON serialization with sorted object keys. Arrays
576
+ * keep their order (DOM order is semantic). Used to produce canonical
577
+ * baseline strings so byte-level comparison is correct.
578
+ */
579
+ function stableJsonStringify(value: unknown): string {
580
+ return JSON.stringify(normalize(value), null, 2);
581
+ }
582
+
583
+ function normalize(value: unknown): unknown {
584
+ if (Array.isArray(value)) return value.map(normalize);
585
+ if (value && typeof value === "object") {
586
+ const obj = value as Record<string, unknown>;
587
+ const out: Record<string, unknown> = {};
588
+ for (const key of Object.keys(obj).sort()) out[key] = normalize(obj[key]);
589
+ return out;
590
+ }
591
+ return value;
592
+ }
593
+
594
+ /**
595
+ * First JSONPath-ish location where `expected`/`actual` differ, plus
596
+ * truncated snippets. Not a general-purpose diff; just an actionable
597
+ * pointer for the error message.
598
+ */
599
+ function firstJsonDiff(
600
+ expected: unknown,
601
+ actual: SerializedNode
602
+ ): { path: string; expectedSnippet: string; actualSnippet: string } {
603
+ function snippet(v: unknown): string {
604
+ // `JSON.stringify(undefined)` returns the value `undefined`
605
+ // (not a string), which then trips `truncate` and was being
606
+ // mis-reported as `<unserializable>`. A missing attribute on
607
+ // one side of the diff is the common case (e.g. a device that
608
+ // emits a non-deterministic, layout-derived attr only on some
609
+ // runs), so name it explicitly instead of the scary fallback.
610
+ if (v === undefined) return "undefined";
611
+ try {
612
+ return truncate(JSON.stringify(v), TREE_DIFF_SNIPPET_MAX_CHARS);
613
+ } catch {
614
+ return "<unserializable>";
615
+ }
616
+ }
617
+ function walk(e: unknown, a: unknown, path: string): ReturnType<typeof firstJsonDiff> | null {
618
+ if (e === a) return null;
619
+ if (typeof e !== typeof a || e === null || a === null) {
620
+ return { path, expectedSnippet: snippet(e), actualSnippet: snippet(a) };
621
+ }
622
+ if (Array.isArray(e) || Array.isArray(a)) {
623
+ if (!Array.isArray(e) || !Array.isArray(a)) {
624
+ return { path, expectedSnippet: snippet(e), actualSnippet: snippet(a) };
625
+ }
626
+ const max = Math.max(e.length, a.length);
627
+ for (let i = 0; i < max; i++) {
628
+ if (i >= e.length || i >= a.length) {
629
+ return {
630
+ path: `${path}[${i}]`,
631
+ expectedSnippet: snippet(e[i]),
632
+ actualSnippet: snippet(a[i])
633
+ };
634
+ }
635
+ const nested = walk(e[i], a[i], `${path}[${i}]`);
636
+ if (nested) return nested;
637
+ }
638
+ return null;
639
+ }
640
+ if (typeof e === "object" && typeof a === "object") {
641
+ const eo = e as Record<string, unknown>;
642
+ const ao = a as Record<string, unknown>;
643
+ const keys = new Set([...Object.keys(eo), ...Object.keys(ao)]);
644
+ for (const key of [...keys].sort()) {
645
+ const nested = walk(eo[key], ao[key], `${path}.${key}`);
646
+ if (nested) return nested;
647
+ }
648
+ return null;
649
+ }
650
+ return { path, expectedSnippet: snippet(e), actualSnippet: snippet(a) };
651
+ }
652
+ const hit = walk(expected, actual, "$");
653
+ if (hit) return hit;
654
+ // Unreachable: caller only invokes us after stringified forms
655
+ // already differ. Sentinel for typecheck.
656
+ return {
657
+ path: "$",
658
+ expectedSnippet: snippet(expected),
659
+ actualSnippet: snippet(actual)
660
+ };
661
+ }