@roku-sdk/device-test-client 0.0.0-stage → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/baselines.md +221 -0
- package/docs/configuration.md +258 -0
- package/docs/dom-assertions.md +264 -0
- package/docs/errors.md +525 -0
- package/docs/examples/deep-link-cold-start.md +85 -0
- package/docs/examples/focus-navigation.md +81 -0
- package/docs/examples/log-driven-assertion.md +99 -0
- package/docs/examples/precondition-skips.md +150 -0
- package/docs/examples/screenshot-regression.md +103 -0
- package/docs/examples/smoke-test.md +69 -0
- package/docs/getting-started.md +195 -0
- package/docs/jest-integration.md +331 -0
- package/docs/log-assertions.md +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -3
- package/src/assertions/assertions.ts +661 -0
- package/src/assertions/baseline-env-guard.ts +43 -0
- package/src/assertions/baselines.ts +178 -0
- package/src/assertions/screenshot-artifacts.ts +39 -0
- package/src/assertions/screenshot-diff.ts +447 -0
- package/src/client/build-cache.ts +292 -0
- package/src/client/client.ts +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -0
- package/src/device/device-class.ts +57 -0
- package/src/device/device-env-sync.ts +58 -0
- package/src/device/device-resolver-sync.ts +43 -0
- package/src/device/device-resolver.ts +141 -0
- package/src/device/lease-provisioning.ts +54 -0
- package/src/errors.ts +773 -0
- package/src/index.ts +186 -0
- package/src/internal/constants.ts +3 -0
- package/src/jest/artifacts.ts +87 -0
- package/src/jest/device-test-environment.ts +420 -0
- package/src/jest/failure-artifacts.ts +169 -0
- package/src/jest/global-setup.ts +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -0
- package/src/jest/jest-setup.ts +53 -0
- package/src/jest/preconditions.ts +239 -0
- package/src/jest/telemetry.ts +331 -0
- package/src/logs/device-log-sink.ts +69 -0
- package/src/logs/log-buffer.ts +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +443 -0
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
|
+
}
|