@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.
- package/BREAKING_CHANGES.md +17 -0
- 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 +544 -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 +349 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +657 -0
- package/docs/screenshot-assertions.md +316 -0
- package/package.json +81 -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 +1964 -0
- package/src/client/deploy.ts +407 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +170 -0
- package/src/client/health.ts +345 -0
- package/src/client/parsers.ts +162 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +520 -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 +774 -0
- package/src/index.ts +181 -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 +137 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +490 -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 +278 -0
- package/src/logs/log-session.ts +436 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +472 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- package/support/paths.ts +13 -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
|
+
}
|