@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
package/src/errors.ts ADDED
@@ -0,0 +1,773 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import type {
4
+ ActiveAppInfo,
5
+ AppUiNode,
6
+ LogLine,
7
+ LogPosition,
8
+ MaskRegion,
9
+ NodePredicate,
10
+ SystemDialogNode
11
+ } from "./types";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Unified error model (M10)
15
+ //
16
+ // Every error class in this file extends `DeviceTestError`, which adds:
17
+ // - `kind`: high-level reporter discriminator (precondition / log / deploy …)
18
+ // - `code`: stable string identifier for programmatic handling
19
+ // (logging filters, retry-policy lookup, telemetry)
20
+ // - `retryable`: hint to deploy/wait retry layers; defaults to `false`
21
+ // because most domain errors are deterministic
22
+ // (a baseline mismatch will not pass on retry).
23
+ // - `cause`: standard ES2022 `Error.cause` slot, populated via the
24
+ // `Error` constructor's `{ cause }` option (Node 20+).
25
+ //
26
+ // Concrete classes are kept (with their existing names, fields, and
27
+ // `name`-property values) so:
28
+ // - `instanceof XError` checks in tests / call sites keep working
29
+ // (heavily relied upon — see `__tests__/expect-screenshot.test.ts`,
30
+ // `__tests__/log-assertions.test.ts`, etc.).
31
+ // - reporters that key off `error.name` keep working.
32
+ // - new code can branch on the more ergonomic `kind` / `code` /
33
+ // `retryable` triple instead of a deepening `instanceof` chain.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Discriminated union of error categories. Reporters branch on `kind`
38
+ * (cheap literal compare); programmatic retry policies branch on
39
+ * `code` and `retryable`.
40
+ */
41
+ export type DeviceTestErrorKind = "precondition" | "log" | "deploy" | "device-health" | "dom" | "screenshot" | "client";
42
+
43
+ /**
44
+ * Stable, machine-readable error code. New codes MUST be added to
45
+ * this union so the reporter / retry-policy switch stays exhaustive.
46
+ */
47
+ export type DeviceTestErrorCode =
48
+ | "PRECONDITION_MISMATCH"
49
+ | "PRECONDITION_MISSING_DEVICE_INFO"
50
+ | "PRECONDITION_NOT_IMPLEMENTED"
51
+ | "LOG_STREAM_GAP"
52
+ | "LOG_WAIT_TIMEOUT"
53
+ | "LOG_UNEXPECTED"
54
+ | "LOG_SESSION_START"
55
+ | "LOG_BUFFER_OVERFLOW"
56
+ | "APP_CRASHED"
57
+ | "RK_CLIENT"
58
+ | "BUILD_FAILED"
59
+ | "DEPLOY_FAILED"
60
+ | "DEVICE_OFFLINE"
61
+ | "DEVICE_UNHEALTHY"
62
+ | "NODE_NOT_FOUND"
63
+ | "FOCUSED_NODE_MISMATCH"
64
+ | "WAIT_FOR_NODE_TIMEOUT"
65
+ | "BASELINE_MISSING"
66
+ | "BASELINE_RESOLUTION"
67
+ | "TREE_MISMATCH"
68
+ | "SCREENSHOT_NOT_STABLE"
69
+ | "SCREENSHOT_MISMATCH"
70
+ | "SCREENSHOT_BASELINE_MISSING"
71
+ | "SCREENSHOT_DIMENSIONS_MISMATCH"
72
+ | "SCREENSHOT_MASK"
73
+ | "SCREENSHOT_ALIGN_TOLERANCE";
74
+
75
+ /**
76
+ * Shared base for every error type in this package. New code paths
77
+ * SHOULD prefer branching on `kind` + `code` over `instanceof` chains;
78
+ * the concrete classes below remain for back-compat with existing
79
+ * `instanceof` checks and reporters that read `error.name`.
80
+ *
81
+ * `cause` flows through the standard ES2022 `Error.cause` channel so
82
+ * `console.error(err)` and Node's default formatter render the chain
83
+ * automatically.
84
+ */
85
+ export class DeviceTestError extends Error {
86
+ public readonly kind: DeviceTestErrorKind;
87
+ /**
88
+ * Stable identifier for programmatic handling. The canonical set
89
+ * is `DeviceTestErrorCode`; we widen the runtime type to `string`
90
+ * so back-compat callers (notably `RkClientError` consumers like
91
+ * `device-resolver-sync.test.ts`) can still surface their domain
92
+ * codes (e.g. `"RK_DEVICE_IP_UNRESOLVED"`) without us having to
93
+ * enumerate every legacy code in the union.
94
+ */
95
+ public readonly code: DeviceTestErrorCode | string;
96
+ public readonly retryable: boolean;
97
+
98
+ constructor(
99
+ message: string,
100
+ opts: {
101
+ kind: DeviceTestErrorKind;
102
+ code: DeviceTestErrorCode | string;
103
+ retryable?: boolean;
104
+ cause?: unknown;
105
+ }
106
+ ) {
107
+ // Forward `cause` through the standard `Error` ctor option so
108
+ // `Error.cause` is set canonically (and serialisers like
109
+ // `util.inspect` render the chain). Skip the option object
110
+ // entirely when no cause is supplied to avoid setting a
111
+ // `cause: undefined` slot, which some test matchers print.
112
+ if (opts.cause === undefined) {
113
+ super(message);
114
+ } else {
115
+ super(message, { cause: opts.cause });
116
+ }
117
+ this.name = "DeviceTestError";
118
+ this.kind = opts.kind;
119
+ this.code = opts.code;
120
+ this.retryable = opts.retryable ?? false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Thrown by `requirePrecondition` when the device does not satisfy a
126
+ * test-declared precondition. `itOnDevice` / `describeOnDevice` convert
127
+ * this into a skip with a clear reason.
128
+ *
129
+ * `reason` discriminates: `"mismatch"` (field value failed),
130
+ * `"missing-device-info"` (cache empty), `"not-implemented"` (field
131
+ * has no ECP query path; see `PreconditionNotImplementedError`).
132
+ */
133
+ export class PreconditionNotMetError extends DeviceTestError {
134
+ /** Precondition field name that failed (e.g. `"firmwareVersion"`). */
135
+ public readonly field: string;
136
+ /** Required value or range (e.g. `">=12.0"`). */
137
+ public readonly expected: string;
138
+ /** Observed value from `DeviceInfo` (e.g. `"11.5"`). */
139
+ public readonly actual: string;
140
+ /** Failure discriminator. See class docstring. */
141
+ public readonly reason: "mismatch" | "missing-device-info" | "not-implemented";
142
+
143
+ constructor(
144
+ message: string,
145
+ context: {
146
+ field: string;
147
+ expected: string;
148
+ actual: string;
149
+ reason?: "mismatch" | "missing-device-info" | "not-implemented";
150
+ }
151
+ ) {
152
+ const reason = context.reason ?? "mismatch";
153
+ super(message, {
154
+ kind: "precondition",
155
+ code:
156
+ reason === "mismatch"
157
+ ? "PRECONDITION_MISMATCH"
158
+ : reason === "missing-device-info"
159
+ ? "PRECONDITION_MISSING_DEVICE_INFO"
160
+ : "PRECONDITION_NOT_IMPLEMENTED"
161
+ });
162
+ this.name = "PreconditionNotMetError";
163
+ this.field = context.field;
164
+ this.expected = context.expected;
165
+ this.actual = context.actual;
166
+ this.reason = reason;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Thrown when a precondition targets a field with no ECP query path
172
+ * (currently: `library`). Extends `PreconditionNotMetError` so skip
173
+ * conversion treats it identically; reporters can still narrow via
174
+ * `instanceof` if they care about the distinction.
175
+ */
176
+ export class PreconditionNotImplementedError extends PreconditionNotMetError {
177
+ /** The unimplemented feature identifier (e.g. `"library-version"`). */
178
+ public readonly feature: string;
179
+
180
+ constructor(message: string, context: { feature: string; field: string; expected: string; actual: string }) {
181
+ super(message, {
182
+ field: context.field,
183
+ expected: context.expected,
184
+ actual: context.actual,
185
+ reason: "not-implemented"
186
+ });
187
+ this.name = "PreconditionNotImplementedError";
188
+ this.feature = context.feature;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Thrown by `waitForLog` / `expectNoLog` when the log stream dropped
194
+ * and reconnected during the wait. `[gapStart, gapEnd)` is the
195
+ * half-open seq range of missing lines (decimal `LogPosition` strings).
196
+ *
197
+ * Marked `retryable: true` — the gap usually means rk-server briefly
198
+ * restarted; a re-issued wait against a fresh `since` typically succeeds.
199
+ */
200
+ export class LogStreamGapError extends DeviceTestError {
201
+ /** Seq of the first missing line (inclusive), as a decimal string. */
202
+ public readonly gapStart: LogPosition;
203
+ /** Seq of the first line observed after reconnect (exclusive), as a decimal string. */
204
+ public readonly gapEnd: LogPosition;
205
+
206
+ constructor(message: string, context: { gapStart?: LogPosition; gapEnd?: LogPosition } = {}) {
207
+ super(message, { kind: "log", code: "LOG_STREAM_GAP", retryable: true });
208
+ this.name = "LogStreamGapError";
209
+ this.gapStart = context.gapStart ?? "";
210
+ this.gapEnd = context.gapEnd ?? "";
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Thrown by `waitForLog` on timeout. Carries pattern, `since`, timeout,
216
+ * and the last 20 observed lines for diagnostics.
217
+ */
218
+ export class WaitForLogTimeoutError extends DeviceTestError {
219
+ public readonly pattern: string;
220
+ public readonly since: LogPosition;
221
+ public readonly timeoutMs: number;
222
+ public readonly linesChecked: number;
223
+ public readonly recentLines: LogLine[];
224
+
225
+ constructor(
226
+ message: string,
227
+ context: {
228
+ pattern: string;
229
+ since: LogPosition;
230
+ timeoutMs: number;
231
+ linesChecked: number;
232
+ recentLines: LogLine[];
233
+ }
234
+ ) {
235
+ super(message, { kind: "log", code: "LOG_WAIT_TIMEOUT" });
236
+ this.name = "WaitForLogTimeoutError";
237
+ this.pattern = context.pattern;
238
+ this.since = context.since;
239
+ this.timeoutMs = context.timeoutMs;
240
+ this.linesChecked = context.linesChecked;
241
+ this.recentLines = context.recentLines;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Thrown by `expectNoLog` when the forbidden pattern matched a line
247
+ * within the window. `matchedLine` carries the offending text.
248
+ */
249
+ export class UnexpectedLogError extends DeviceTestError {
250
+ public readonly pattern: string;
251
+ public readonly matchedLine: LogLine;
252
+ public readonly since: LogPosition;
253
+
254
+ constructor(message: string, context: { pattern: string; matchedLine: LogLine; since: LogPosition }) {
255
+ super(message, { kind: "log", code: "LOG_UNEXPECTED" });
256
+ this.name = "UnexpectedLogError";
257
+ this.pattern = context.pattern;
258
+ this.matchedLine = context.matchedLine;
259
+ this.since = context.since;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Thrown by `LogSession.ensureStarted` on unrecoverable POST `/logs/start`
265
+ * errors. 400 "already active" is NOT a failure — the session attaches
266
+ * to the existing server-side session.
267
+ */
268
+ export class LogSessionStartError extends DeviceTestError {
269
+ public readonly serverUrl: string;
270
+ public readonly statusCode?: number;
271
+ public readonly detail?: string;
272
+
273
+ constructor(message: string, context: { serverUrl: string; statusCode?: number; detail?: string }) {
274
+ super(message, { kind: "log", code: "LOG_SESSION_START" });
275
+ this.name = "LogSessionStartError";
276
+ this.serverUrl = context.serverUrl;
277
+ this.statusCode = context.statusCode;
278
+ this.detail = context.detail;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Thrown when `pos` references a seq below the buffer's minimum
284
+ * retained seq (eviction dropped the lines the caller asked about).
285
+ * Distinct from `LogStreamGapError` — overflow is a host-side retention
286
+ * issue, not a device→host stream loss.
287
+ */
288
+ export class LogBufferOverflowError extends DeviceTestError {
289
+ public readonly requested: LogPosition;
290
+ public readonly minAvailable: LogPosition;
291
+
292
+ constructor(message: string, context: { requested: LogPosition; minAvailable: LogPosition }) {
293
+ super(message, { kind: "log", code: "LOG_BUFFER_OVERFLOW" });
294
+ this.name = "LogBufferOverflowError";
295
+ this.requested = context.requested;
296
+ this.minAvailable = context.minAvailable;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Thrown when `/query/active-app` shows the app returned to the home
302
+ * screen mid-test (a proxy for a crash). `activeApp` carries the
303
+ * observed `ActiveAppInfo` snapshot.
304
+ */
305
+ export class AppCrashedError extends DeviceTestError {
306
+ constructor(
307
+ message: string,
308
+ /** Active-app identity observed at the time of the crash check. */
309
+ public readonly activeApp: ActiveAppInfo,
310
+ /** Optional polling context (predicate, last tree, etc.). */
311
+ public readonly context?: unknown
312
+ ) {
313
+ super(message, { kind: "deploy", code: "APP_CRASHED" });
314
+ this.name = "AppCrashedError";
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Framework-internal errors (invalid key, missing device IP, etc.).
320
+ * Wire errors surface as `ToolingError` and propagate as-is.
321
+ *
322
+ * `code` is caller-supplied (e.g. `"RK_DEVICE_IP_UNRESOLVED"`,
323
+ * `"PRECONDITION_NO_DEVICE_INFO"`); when omitted it defaults to the
324
+ * generic `"RK_CLIENT"`. Reporters should treat any RkClientError
325
+ * with `kind === "client"` as a client-side framework bug and surface
326
+ * the more specific `code` for actionable telemetry.
327
+ */
328
+ export class RkClientError extends DeviceTestError {
329
+ constructor(
330
+ message: string,
331
+ /** Stable error code for programmatic handling. */
332
+ code?: string
333
+ ) {
334
+ super(message, { kind: "client", code: code ?? "RK_CLIENT" });
335
+ this.name = "RkClientError";
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Thrown by `buildAndDeploy` when the host-side build step fails.
341
+ * Device is NOT marked unhealthy — a code fix may resolve it without
342
+ * any device intervention.
343
+ */
344
+ export class BuildFailedError extends DeviceTestError {
345
+ constructor(
346
+ message: string,
347
+ /** Absolute path to the app directory that failed to build. */
348
+ public readonly appPath: string,
349
+ /**
350
+ * Underlying error, if any — typically a `BuildError` from
351
+ * tooling. Forwarded into `Error.cause` (the standard ES2022
352
+ * slot) so `err.cause` still reads as expected for callers
353
+ * that used the pre-M10 public field.
354
+ */
355
+ cause?: unknown
356
+ ) {
357
+ super(message, { kind: "deploy", code: "BUILD_FAILED", cause });
358
+ this.name = "BuildFailedError";
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Thrown by `buildAndDeploy` / `launch` on device-side failure
364
+ * (sideload rejected, unreachable, readiness timeout, system dialog).
365
+ * Always paired with `markDeviceUnhealthy(...)` so subsequent tests
366
+ * fail fast via the per-worker skip-hook.
367
+ */
368
+ export class DeployFailedError extends DeviceTestError {
369
+ constructor(
370
+ message: string,
371
+ /**
372
+ * Structured context for post-mortem reporting. Contents vary by
373
+ * failure mode; all fields are optional so the reporter can
374
+ * render what is available.
375
+ */
376
+ public readonly context: {
377
+ appPath?: string;
378
+ deviceIp?: string;
379
+ /** Formatted output of `runSideloadDiagnostics` when sideload failed. */
380
+ diagnostics?: string;
381
+ /** Last tree observed before timeout (readiness failure). */
382
+ lastTree?: AppUiNode;
383
+ /** Last active-app observed before timeout (readiness failure). */
384
+ lastActiveApp?: ActiveAppInfo;
385
+ /** Dialog node seen on post-deploy tree inspection. */
386
+ dialog?: SystemDialogNode;
387
+ /** Which deploy sub-phase produced the failure (launch / readiness / etc.). */
388
+ phase?: "launch" | "readiness" | "sideload" | "config";
389
+ /** Channel id that was the launch target, if applicable. */
390
+ expectedChannelId?: string;
391
+ /** Specific failure reason tag (e.g. `"never-left-home"`, `"readiness-timeout"`). */
392
+ reason?: string;
393
+ /** Underlying error, if any (e.g. ToolingError). */
394
+ cause?: unknown;
395
+ } = {}
396
+ ) {
397
+ super(message, { kind: "deploy", code: "DEPLOY_FAILED", cause: context.cause });
398
+ this.name = "DeployFailedError";
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Thrown by `waitForDeviceOnline` when no `/query/device-info` probe
404
+ * succeeds within the budget. Distinct from `DeployFailedError` so
405
+ * retry/skip logic can tell "device absent" from "deploy failed".
406
+ *
407
+ * Marked `retryable: true` — device-online failures are usually
408
+ * transient (network flake, just-rebooting device).
409
+ */
410
+ export class DeviceOfflineError extends DeviceTestError {
411
+ constructor(
412
+ message: string,
413
+ public readonly context: {
414
+ /** Target device IP the probe was issued against. */
415
+ deviceIp: string;
416
+ /** Total time budget the probe was allowed to spend, in ms. */
417
+ timeoutMs: number;
418
+ /** Number of probes attempted inside the budget. */
419
+ attempts: number;
420
+ /** Last error observed from `/query/device-info`, if any. */
421
+ lastError?: unknown;
422
+ /**
423
+ * Which liveness arm exhausted its budget. `"ecp"` (default)
424
+ * for `/query/device-info` failures via `waitForDeviceOnline`;
425
+ * `"installer"` when the ECP arm passed but the sideload
426
+ * installer (port 80) never accepted a TCP connection.
427
+ * Reporters and `installDeployGuard` retry policy can branch
428
+ * on this without parsing the message string.
429
+ */
430
+ reason?: "ecp" | "installer";
431
+ /**
432
+ * Status reported by the last `tcpProbe` against the
433
+ * installer port, when `reason === "installer"`.
434
+ */
435
+ tcpStatus?: string;
436
+ }
437
+ ) {
438
+ super(message, {
439
+ kind: "device-health",
440
+ code: "DEVICE_OFFLINE",
441
+ retryable: true,
442
+ cause: context.lastError
443
+ });
444
+ this.name = "DeviceOfflineError";
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Thrown by `installDeviceTestHooks`' `beforeEach` when the device was
450
+ * marked unhealthy by an earlier test. Throws (not `test.skip`) since
451
+ * Jest can't produce a `skipped` status from `beforeEach` without a
452
+ * custom reporter; failing fast still avoids cascading failures.
453
+ */
454
+ export class DeviceUnhealthyError extends DeviceTestError {
455
+ constructor(
456
+ message: string,
457
+ /** The original reason passed to `markDeviceUnhealthy`. */
458
+ public readonly reason: string
459
+ ) {
460
+ super(message, { kind: "device-health", code: "DEVICE_UNHEALTHY" });
461
+ this.name = "DeviceUnhealthyError";
462
+ }
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // DOM-assertion errors
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /**
470
+ * Thrown by `findNode` / `findFocusedNode` when the predicate matches
471
+ * nothing. `findAllNodes` does NOT throw — empty results are legitimate.
472
+ */
473
+ export class NodeNotFoundError extends DeviceTestError {
474
+ constructor(
475
+ message: string,
476
+ /** The predicate that matched nothing. */
477
+ public readonly predicate: NodePredicate,
478
+ /** Truncated tree-summary snapshot for diagnostics. */
479
+ public readonly treeSummary: string
480
+ ) {
481
+ super(message, { kind: "dom", code: "NODE_NOT_FOUND" });
482
+ this.name = "NodeNotFoundError";
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Thrown by `expectFocused` when the focused node does not match the
488
+ * predicate (a focused node exists, but it's the wrong one).
489
+ */
490
+ export class FocusedNodeMismatchError extends DeviceTestError {
491
+ constructor(
492
+ message: string,
493
+ /** Predicate the focused node was expected to satisfy. */
494
+ public readonly expectedPredicate: NodePredicate,
495
+ /**
496
+ * Summary of the focused node observed on the tree. Intentionally
497
+ * a summary (not the full `AppUiNode`) so the error payload is
498
+ * bounded — the whole tree is already in the enclosing test's
499
+ * artifacts bundle.
500
+ */
501
+ public readonly actualFocusedNode: string
502
+ ) {
503
+ super(message, { kind: "dom", code: "FOCUSED_NODE_MISMATCH" });
504
+ this.name = "FocusedNodeMismatchError";
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Thrown by `waitForNode` on timeout. `lastTree` is the final snapshot
510
+ * observed (`null` if the first `queryAppUi` rejected before producing one).
511
+ */
512
+ export class WaitForNodeTimeoutError extends DeviceTestError {
513
+ public readonly context: {
514
+ predicate: NodePredicate;
515
+ timeoutMs: number;
516
+ lastTree: AppUiNode | null;
517
+ };
518
+
519
+ constructor(message: string, context: { predicate: NodePredicate; timeoutMs: number; lastTree: AppUiNode | null }) {
520
+ super(message, { kind: "dom", code: "WAIT_FOR_NODE_TIMEOUT" });
521
+ this.name = "WaitForNodeTimeoutError";
522
+ this.context = context;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Thrown by `expectTree(...).toMatchBaseline(path)` when the baseline
528
+ * file is absent and `UPDATE_BASELINES=1` is unset.
529
+ */
530
+ export class BaselineMissingError extends DeviceTestError {
531
+ constructor(
532
+ message: string,
533
+ /** Absolute file path that was attempted. */
534
+ public readonly path: string,
535
+ /** Resolution subdir (`"hd"` or `"fhd"`) chosen for this run. */
536
+ public readonly resolution: string,
537
+ /** Original `DeviceInfo.videoMode` the resolution was derived from, if any. */
538
+ public readonly videoMode: string | undefined
539
+ ) {
540
+ super(message, { kind: "dom", code: "BASELINE_MISSING" });
541
+ this.name = "BaselineMissingError";
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Thrown by `resolveResolution()` when the `hd`/`fhd` subdir cannot be
547
+ * determined. When the active client reports an unsupported videoMode
548
+ * we fail rather than fall back to the env override (a recognised
549
+ * videoMode is authoritative; mismatch means operator config is wrong).
550
+ *
551
+ * `reason`: `"unsupported-videoMode"`, `"no-active-client-and-no-env"`,
552
+ * or `"invalid-env-value"`.
553
+ */
554
+ export class BaselineResolutionError extends DeviceTestError {
555
+ constructor(
556
+ message: string,
557
+ public readonly context: {
558
+ videoMode?: string;
559
+ reason: "unsupported-videoMode" | "no-active-client-and-no-env" | "invalid-env-value";
560
+ }
561
+ ) {
562
+ super(message, { kind: "dom", code: "BASELINE_RESOLUTION" });
563
+ this.name = "BaselineResolutionError";
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Thrown by `expectTree(...).toMatchBaseline(path)` on tree mismatch.
569
+ * `firstDiff` is a JSONPath-ish key; `expected/actualSnippet` are
570
+ * truncated for bounded payload size.
571
+ */
572
+ export class TreeMismatchError extends DeviceTestError {
573
+ constructor(
574
+ message: string,
575
+ public readonly context: {
576
+ /** Absolute baseline path on disk. */
577
+ baselinePath: string;
578
+ /** JSONPath of the first differing key (e.g. `$.children[0].attrs.opacity`). */
579
+ firstDiff: string;
580
+ /** Truncated serialization of the baseline value at `firstDiff`. */
581
+ expectedSnippet: string;
582
+ /** Truncated serialization of the actual value at `firstDiff`. */
583
+ actualSnippet: string;
584
+ }
585
+ ) {
586
+ super(message, { kind: "dom", code: "TREE_MISMATCH" });
587
+ this.name = "TreeMismatchError";
588
+ }
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Screenshot-assertion errors
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /**
596
+ * Thrown by `captureStableScreenshot` on convergence timeout.
597
+ * `lastPng` is excluded from `toJSON()` to keep `JSON.stringify(err)`
598
+ * small; reporters that need the bytes read it directly.
599
+ * `lastDiffCount` + `firstDiffPoint` + `maskedRegions` together
600
+ * disambiguate "wrong mask" from "app still animating".
601
+ */
602
+ export class ScreenshotNotStableError extends DeviceTestError {
603
+ public readonly timeoutMs: number;
604
+ public readonly iterations: number;
605
+ public readonly lastDiffCount: number;
606
+ public readonly maskedRegions: MaskRegion[];
607
+ public readonly firstDiffPoint?: { x: number; y: number };
608
+ public readonly lastPng?: Buffer;
609
+
610
+ constructor(
611
+ message: string,
612
+ context: {
613
+ timeoutMs: number;
614
+ iterations: number;
615
+ lastDiffCount: number;
616
+ maskedRegions: MaskRegion[];
617
+ firstDiffPoint?: { x: number; y: number };
618
+ lastPng?: Buffer;
619
+ }
620
+ ) {
621
+ super(message, { kind: "screenshot", code: "SCREENSHOT_NOT_STABLE" });
622
+ this.name = "ScreenshotNotStableError";
623
+ this.timeoutMs = context.timeoutMs;
624
+ this.iterations = context.iterations;
625
+ this.lastDiffCount = context.lastDiffCount;
626
+ this.maskedRegions = context.maskedRegions;
627
+ this.firstDiffPoint = context.firstDiffPoint;
628
+ this.lastPng = context.lastPng;
629
+ }
630
+
631
+ /** JSON projection that OMITS `lastPng` to keep payloads small. */
632
+ toJSON(): Record<string, unknown> {
633
+ return {
634
+ name: this.name,
635
+ message: this.message,
636
+ timeoutMs: this.timeoutMs,
637
+ iterations: this.iterations,
638
+ lastDiffCount: this.lastDiffCount,
639
+ maskedRegions: this.maskedRegions,
640
+ firstDiffPoint: this.firstDiffPoint
641
+ };
642
+ }
643
+ }
644
+
645
+ interface ScreenshotMismatchContext {
646
+ baselinePath: string;
647
+ resolution: "hd" | "fhd";
648
+ actual: { w: number; h: number };
649
+ mismatchedPixels: number;
650
+ totalPixels: number;
651
+ ratio: number;
652
+ threshold: number;
653
+ diffPath: string;
654
+ /**
655
+ * Translation applied to the capture before scoring, when
656
+ * `alignTolerance` was set and the untranslated comparison failed.
657
+ * Absent when no alignment search ran. A non-zero value that still
658
+ * failed means the surface moved AND changed.
659
+ */
660
+ alignment?: { dx: number; dy: number };
661
+ }
662
+
663
+ /**
664
+ * Thrown when the mismatched-pixel ratio (outside masks) exceeds the
665
+ * caller's `threshold`. The diff PNG is always written to disk first;
666
+ * `diffPath` is its absolute location.
667
+ */
668
+ export class ScreenshotMismatchError extends DeviceTestError {
669
+ public readonly context: ScreenshotMismatchContext;
670
+
671
+ constructor(message: string, context: ScreenshotMismatchContext) {
672
+ super(message, { kind: "screenshot", code: "SCREENSHOT_MISMATCH" });
673
+ this.name = "ScreenshotMismatchError";
674
+ this.context = context;
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Thrown when the baseline PNG is absent and `UPDATE_BASELINES=1` is
680
+ * unset. Distinct from `BaselineMissingError` (DOM baselines).
681
+ */
682
+ export class ScreenshotBaselineMissingError extends DeviceTestError {
683
+ constructor(
684
+ message: string,
685
+ public readonly baselinePath: string,
686
+ public readonly resolution: "hd" | "fhd"
687
+ ) {
688
+ super(message, { kind: "screenshot", code: "SCREENSHOT_BASELINE_MISSING" });
689
+ this.name = "ScreenshotBaselineMissingError";
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Thrown when baseline and capture have different dimensions. We
695
+ * refuse to run `pixelmatch` at mismatched sizes (every pixel would
696
+ * miss; diff would be useless) and write no diff file.
697
+ */
698
+ export class ScreenshotDimensionsMismatchError extends DeviceTestError {
699
+ public readonly context: {
700
+ baselinePath: string;
701
+ resolution: "hd" | "fhd";
702
+ baseline: { w: number; h: number };
703
+ actual: { w: number; h: number };
704
+ };
705
+
706
+ constructor(
707
+ message: string,
708
+ context: {
709
+ baselinePath: string;
710
+ resolution: "hd" | "fhd";
711
+ baseline: { w: number; h: number };
712
+ actual: { w: number; h: number };
713
+ }
714
+ ) {
715
+ super(message, { kind: "screenshot", code: "SCREENSHOT_DIMENSIONS_MISMATCH" });
716
+ this.name = "ScreenshotDimensionsMismatchError";
717
+ this.context = context;
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Thrown by `validateMasks` for `zero-area` or `out-of-bounds` regions.
723
+ * Partial overlap is NOT an error — such regions are clamped, letting
724
+ * an FHD mask survive on an HD device.
725
+ */
726
+ export class ScreenshotMaskError extends DeviceTestError {
727
+ public readonly context: {
728
+ region: MaskRegion;
729
+ canvas: { w: number; h: number };
730
+ reason: "out-of-bounds" | "zero-area";
731
+ };
732
+
733
+ constructor(
734
+ message: string,
735
+ context: {
736
+ region: MaskRegion;
737
+ canvas: { w: number; h: number };
738
+ reason: "out-of-bounds" | "zero-area";
739
+ }
740
+ ) {
741
+ super(message, { kind: "screenshot", code: "SCREENSHOT_MASK" });
742
+ this.name = "ScreenshotMaskError";
743
+ this.context = context;
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Thrown by `validateAlignTolerance` for a non-integer, negative, or
749
+ * over-cap `alignTolerance` axis. Always a caller bug, so it is loud
750
+ * rather than clamped.
751
+ */
752
+ export class ScreenshotAlignToleranceError extends DeviceTestError {
753
+ public readonly context: {
754
+ axis: "x" | "y";
755
+ value: number;
756
+ max: number;
757
+ reason: "not-an-integer" | "negative" | "over-cap";
758
+ };
759
+
760
+ constructor(
761
+ message: string,
762
+ context: {
763
+ axis: "x" | "y";
764
+ value: number;
765
+ max: number;
766
+ reason: "not-an-integer" | "negative" | "over-cap";
767
+ }
768
+ ) {
769
+ super(message, { kind: "screenshot", code: "SCREENSHOT_ALIGN_TOLERANCE" });
770
+ this.name = "ScreenshotAlignToleranceError";
771
+ this.context = context;
772
+ }
773
+ }