@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/types.ts ADDED
@@ -0,0 +1,443 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ export interface DeviceInfo {
4
+ modelName: string;
5
+ modelNumber: string;
6
+ modelFamily: string;
7
+ deviceClass: string;
8
+ firmwareVersion: string;
9
+ videoMode: string;
10
+ locale: string;
11
+ country: string;
12
+ libraries: Record<string, string>;
13
+ }
14
+
15
+ export interface AppUiNode {
16
+ /** XML tag name (e.g. `"Label"`, `"RowList"`). */
17
+ tag: string;
18
+ /**
19
+ * Raw ECP attributes verbatim (all values are strings; numeric
20
+ * comparisons require parsing). `bounds`/`focused` are also kept
21
+ * here as strings so attribute iteration is exhaustive.
22
+ */
23
+ attrs: Record<string, string>;
24
+ bounds: { x: number; y: number; width: number; height: number };
25
+ focused: boolean;
26
+ children: AppUiNode[];
27
+ }
28
+
29
+ export interface NodePredicate {
30
+ tag?: string;
31
+ attr?: Record<string, string>;
32
+ focused?: boolean;
33
+ }
34
+
35
+ export interface LibraryRequirement {
36
+ /** Library identifier as reported by Roku ECP (`rida`, etc.). */
37
+ name: string;
38
+ /** Semver range (e.g. `">=2.0"`) — matched against the library's reported version. */
39
+ version: string;
40
+ }
41
+
42
+ /**
43
+ * Test-declared requirements for `requirePrecondition` / `itOnDevice` /
44
+ * `describeOnDevice`.
45
+ *
46
+ * Match strategy: `firmwareVersion` semver range (4-part Roku versions
47
+ * coerced to MAJOR.MINOR.PATCH); `modelFamily` exact;
48
+ * `deviceClass` exact against a derived perf tier (NOT `DeviceInfo.deviceClass`,
49
+ * which is form-factor); `videoMode` prefix; `locale`/`country` exact.
50
+ * `library` throws `PreconditionNotImplementedError` (no ECP query path).
51
+ *
52
+ * Evaluation is in key-insertion order, so the caller controls which
53
+ * field produces the error when multiple mismatch.
54
+ */
55
+ export interface Precondition {
56
+ firmwareVersion?: string;
57
+ modelFamily?: string;
58
+ deviceClass?: string;
59
+ videoMode?: string;
60
+ locale?: string;
61
+ country?: string;
62
+ library?: LibraryRequirement;
63
+ }
64
+
65
+ export interface MaskRegion {
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ }
71
+
72
+ export interface WaitOptions {
73
+ timeout: number;
74
+ interval?: number;
75
+ }
76
+
77
+ export interface CaptureStableOptions {
78
+ timeout: number;
79
+ interval?: number;
80
+ mask?: MaskRegion[];
81
+ /**
82
+ * Maximum number of differing pixels (outside `mask`) between two
83
+ * consecutive captures that still counts as "stable". Defaults to
84
+ * `0` — strict pixel equality, the historical contract.
85
+ *
86
+ * Use a small positive value when the firmware's anti-aliasing
87
+ * pipeline emits sub-pixel jitter on static text (~50–500 pixels
88
+ * per frame on FHD) even after the user-visible content has
89
+ * settled. Pair with `expectScreenshot(...).toMatchBaseline(..., {
90
+ * threshold })` whose `threshold` covers the same tolerance — the
91
+ * stability gate must not be stricter than the assertion gate,
92
+ * otherwise `captureStableScreenshot` can time out on a screen
93
+ * the baseline would have accepted.
94
+ */
95
+ tolerance?: number;
96
+ }
97
+
98
+ export interface KeySequenceOptions {
99
+ keyDelay?: number;
100
+ }
101
+
102
+ export interface WaitForDeviceOnlineOptions {
103
+ /** Total wait budget; defaults to `DEVICE_ONLINE_DEFAULT_TIMEOUT_MS` (180s). */
104
+ timeoutMs?: number;
105
+ /** Probe interval; defaults to `DEVICE_ONLINE_POLL_INTERVAL_MS` (2s). */
106
+ intervalMs?: number;
107
+ }
108
+
109
+ /**
110
+ * Options for `RkClient.waitForDeviceFullyReady`. The method gates on
111
+ * BOTH the ECP responder (port 8060, via `/query/device-info`) AND the
112
+ * sideload installer (port 80, via TCP connect). Each phase has an
113
+ * independent budget so a slow installer cannot starve the ECP probe
114
+ * (and vice versa); the overall wall time is bounded by their sum.
115
+ *
116
+ * Why two phases: the original `waitForDeviceOnline` only proves the
117
+ * device is responding on ECP. After a crash/reboot the installer (the
118
+ * port `rk` POSTs the sideload package to) lags ECP recovery by several
119
+ * seconds; retrying a deploy in that window surfaces the misleading
120
+ * "Device is not reachable on the network" diagnostic from
121
+ * `sideload-diagnostics`. Probing both before the next deploy attempt
122
+ * eliminates that whole failure class — see `installDeployGuard`.
123
+ */
124
+ export interface WaitForDeviceFullyReadyOptions {
125
+ /**
126
+ * Budget for the ECP `/query/device-info` arm. Defaults to
127
+ * `DEVICE_ONLINE_DEFAULT_TIMEOUT_MS` (180s). Forwarded verbatim to
128
+ * the underlying `waitForDeviceOnline` call.
129
+ */
130
+ ecpTimeoutMs?: number;
131
+ /**
132
+ * Probe interval for the ECP arm. Defaults to
133
+ * `DEVICE_ONLINE_POLL_INTERVAL_MS` (2s).
134
+ */
135
+ ecpIntervalMs?: number;
136
+ /**
137
+ * Budget for the installer-port (TCP/80) arm, applied AFTER the ECP
138
+ * arm resolves. Defaults to `INSTALLER_PORT_PROBE_DEFAULT_TIMEOUT_MS`
139
+ * (60s).
140
+ */
141
+ installerTimeoutMs?: number;
142
+ /**
143
+ * Probe interval for the installer-port arm. Defaults to
144
+ * `INSTALLER_PORT_PROBE_INTERVAL_MS` (1s).
145
+ */
146
+ installerIntervalMs?: number;
147
+ }
148
+
149
+ export type LogPosition = string;
150
+
151
+ export type LogLine = string;
152
+
153
+ export interface LogQueryOptions {
154
+ since: LogPosition;
155
+ timeout?: number;
156
+ window?: number;
157
+ }
158
+
159
+ export interface LaunchOptions {
160
+ /**
161
+ * ECP channel id to launch. Defaults to `"dev"` (the currently
162
+ * sideloaded development channel). Published channels use their
163
+ * numeric id (e.g. `"12345"`).
164
+ */
165
+ channelId?: string;
166
+ /**
167
+ * Deep-link params serialized into the launch URL. Reliable on
168
+ * `launch()` (the runtime is already subscribed); cold sideloads
169
+ * must use `buildAndLaunchWithParams` instead.
170
+ */
171
+ deepLink?: Record<string, string>;
172
+ /**
173
+ * Post-launch readiness budget; defaults to `READINESS_TIMEOUT_MS`.
174
+ * On timeout: `DeployFailedError`, device flagged unhealthy.
175
+ */
176
+ readinessTimeout?: number;
177
+ /**
178
+ * Semantic readiness gate. See `BuildAndDeployOptions.readyNode`
179
+ * for picking-the-predicate guidance.
180
+ */
181
+ readyNode?: NodePredicate;
182
+ }
183
+
184
+ export interface BuildAndDeployOptions {
185
+ /**
186
+ * Deep-link params for the post-sideload `/launch/dev?…` POST.
187
+ *
188
+ * Cold-mount race: the POST frequently lands before the runtime
189
+ * subscribes to `setLaunchParamsUpdateHandler`, and firmware
190
+ * silently drops it. Readiness passing is NOT evidence the params
191
+ * arrived. Use this only for apps that ignore params, read them
192
+ * synchronously on mount, or gate on a fixture-emitted log line.
193
+ * For cold sideloads that must land in a deep-linked state, use
194
+ * `buildAndLaunchWithParams` instead.
195
+ */
196
+ deepLink?: Record<string, string>;
197
+ /** Readiness budget; defaults to `READINESS_TIMEOUT_MS` (10s). */
198
+ readinessTimeout?: number;
199
+ /** ECP channel id to launch after sideload; defaults to `"dev"`. */
200
+ channelId?: string;
201
+ /** Override device selector; defaults to active `.roku/` device. */
202
+ deviceSelector?: string;
203
+ /**
204
+ * Semantic readiness gate: a predicate matching a user-rendered
205
+ * element. Without it, readiness fires on the SceneGraph skeleton
206
+ * (mounted before user code), so `buildAndDeploy` can return
207
+ * "success" while `main()` is still compiling or crashing during
208
+ * mount (`EXIT_APP_ERROR` 0.3–1.0s after splash). Pick a stable
209
+ * Label with a known id+text; avoid wrapper tags like `Group`.
210
+ *
211
+ * Once matched, readiness still requires two consecutive passing
212
+ * polls — catches "mounted then immediately crashed" as
213
+ * `DeployFailedError({ reason: "crashed-after-ready" })`.
214
+ * Omitting falls back to `tree.children.length > 0`.
215
+ */
216
+ readyNode?: NodePredicate;
217
+ /**
218
+ * Override esbuild's `drop: ["console", "debugger"]` pass:
219
+ * `true` always drops (perf profiling), `false` never drops (for
220
+ * fixtures that exercise console behaviour, e.g. polyfill
221
+ * verifiers), `undefined` falls back to the historical default
222
+ * (drop on `rk build`, keep on `rk dev`).
223
+ */
224
+ dropConsole?: boolean;
225
+ /**
226
+ * Build with development artifacts — no minification, readable identifiers,
227
+ * source maps. Equivalent to `rk build --debug`. Useful for profiling sessions
228
+ * where signal/effect names and stack frames remain human-readable.
229
+ */
230
+ dev?: boolean;
231
+ /**
232
+ * Enable SolidJS dev mode — imports the `solid-js/dev` build so signal
233
+ * and effect names remain readable in Perfetto tracepoints. Equivalent to
234
+ * `rk build --solid-dev-mode` or `buildVariables: { __SIGNAL__: true }` in
235
+ * `roku-config.ts`.
236
+ */
237
+ solidDevMode?: boolean;
238
+ /**
239
+ * Build-time variable overrides merged on top of the app's `roku-config.ts`
240
+ * `buildVariables`. Pass `{ __SIGNAL__: true }` to enable SolidJS dev mode
241
+ * (readable signal/effect names in Perfetto tracepoints).
242
+ */
243
+ buildVariables?: Record<string, boolean | string | number>;
244
+ }
245
+
246
+ /**
247
+ * Options for `buildAndLaunchWithParams`. Both log regexes are
248
+ * required: omitting either re-introduces the cold-mount race the
249
+ * primitive exists to avoid.
250
+ */
251
+ export interface BuildAndLaunchWithParamsOptions {
252
+ /** Device selector for phase-1 `buildAndDeploy`. */
253
+ deviceSelector?: string;
254
+ /** ECP channel id for both phases; defaults to `"dev"`. */
255
+ channelId?: string;
256
+ /** Readiness timeout forwarded to BOTH phases. */
257
+ readinessTimeout?: number;
258
+ /** Phase-1 semantic readiness gate; see `BuildAndDeployOptions.readyNode`. */
259
+ readyNode?: NodePredicate;
260
+ /**
261
+ * Required. Log regex emitted once the runtime's launch-params
262
+ * subscription is registered (e.g. an effect reading
263
+ * `useLaunchConfig()` for the first time). Gating phase 2 on this
264
+ * prevents the params POST from racing handler registration.
265
+ */
266
+ runtimeReadyLogPattern: RegExp;
267
+ /** "Runtime ready" gate timeout; defaults to 5s. */
268
+ runtimeReadyTimeout?: number;
269
+ /**
270
+ * Required. Log regex emitted when phase-2 params are observed by
271
+ * the reactive graph. Gating on this separates signal-delivery
272
+ * failures from renderer regressions in subsequent DOM timeouts.
273
+ */
274
+ paramsAckLogPattern: RegExp;
275
+ /** "Params ack" gate timeout; defaults to 5s. */
276
+ paramsAckTimeout?: number;
277
+ /** Forwarded to phase-1; see `BuildAndDeployOptions.dropConsole`. */
278
+ dropConsole?: boolean;
279
+ }
280
+
281
+ /**
282
+ * Narrow projection of `AppUiNode` for system-dialog surfacing.
283
+ * `tag`/`title` are top-level for reporter convenience; `node` keeps
284
+ * the full underlying tree for deeper inspection.
285
+ */
286
+ export interface SystemDialogNode {
287
+ /** XML tag that matched the `SYSTEM_DIALOG_TAGS` heuristic. */
288
+ tag: string;
289
+ /** `attrs.title` on the node, if present (common on StandardMessageDialog). */
290
+ title: string;
291
+ /** Full underlying AppUiNode, for reporters that want context. */
292
+ node: AppUiNode;
293
+ }
294
+
295
+ /**
296
+ * Max whole-pixel translation the comparator may apply to the capture
297
+ * before scoring it against the baseline. Both axes default to 0
298
+ * (exact positional match) and are capped at
299
+ * `ALIGN_TOLERANCE_MAX_PX` — needing more than a few pixels means the
300
+ * baseline content is genuinely different, not merely offset.
301
+ *
302
+ * Intended for firmware-drawn surfaces (system OSD dialogs) whose
303
+ * internal row heights round differently per device, re-centering the
304
+ * whole surface by 1-3px. App-rendered content is positionally
305
+ * deterministic and must not use this.
306
+ */
307
+ export interface AlignTolerance {
308
+ x?: number;
309
+ y?: number;
310
+ }
311
+
312
+ export interface ScreenshotMatchOptions {
313
+ /**
314
+ * Max fraction of unmasked pixels that may differ; defaults to
315
+ * 0.01 (1%). Per-pixel color sensitivity (YIQ 0.1) is internal
316
+ * and decoupled — widening this does not loosen per-pixel matching.
317
+ */
318
+ threshold?: number;
319
+ mask?: MaskRegion[];
320
+ /**
321
+ * Allow the capture to be translated by up to this many pixels on
322
+ * each axis to absorb device-dependent re-centering. Only consulted
323
+ * after an untranslated comparison has already failed, so it can
324
+ * rescue a failing assertion but never relax a passing one.
325
+ */
326
+ alignTolerance?: AlignTolerance;
327
+ }
328
+
329
+ export interface TreeMatchOptions {
330
+ /** Attribute keys stripped recursively before serialization. */
331
+ exclude?: readonly string[];
332
+ /**
333
+ * Node tags serialized as leaves: the node itself is asserted, its
334
+ * children are not. Use for native nodes whose internal chrome the
335
+ * app does not own and firmware restructures between builds.
336
+ */
337
+ excludeChildrenOf?: readonly string[];
338
+ }
339
+
340
+ /**
341
+ * `/query/active-app` result. Every field stays faithful to the XML, so
342
+ * decide "is this the home screen?" with `isHomeScreenActiveApp` rather
343
+ * than comparing fields directly — firmware ships several distinct home
344
+ * surfaces and no single field identifies all of them.
345
+ */
346
+ export interface ActiveAppInfo {
347
+ /**
348
+ * `"dev"` for sideloaded, channel id for published. Empty on the
349
+ * classic home screen, but newer firmware reports a real id for its
350
+ * home surfaces (`"562859"` for Roku Dynamic Menu).
351
+ */
352
+ id: string;
353
+ /**
354
+ * User-facing title (text node of `<app>`). `"Roku"` on the classic
355
+ * home screen; other home surfaces use their own name, e.g.
356
+ * `"Roku Dynamic Menu"`.
357
+ */
358
+ title: string;
359
+ /** ECP `type` attribute (`"appl"`, `"home"`, `"tvin"`, …); `""` when absent. */
360
+ type: string;
361
+ /**
362
+ * ECP `ui-location` attribute: `"home"` for the home experience, and
363
+ * the channel's own id while that channel is foregrounded. `""` when
364
+ * absent, as on firmware that predates the attribute.
365
+ */
366
+ uiLocation: string;
367
+ }
368
+
369
+ /** ECP request categories. Keys in `ECP_INTERVAL_MS` must match. */
370
+ export type EcpCategory = "query-appui" | "query-device-info" | "query-active-app" | "keypress" | "launch" | "capture";
371
+
372
+ export interface RkClientOptions {
373
+ /** Device IPv4. Required directly; `createDefaultClient()` resolves from env/`.roku/`. */
374
+ deviceIp?: string;
375
+ /** Default inter-key delay for `keysequence` (ms). */
376
+ keyDelay?: number;
377
+ /** Per-request ECP timeout (ms); capture uses its own hard-coded longer timeout. */
378
+ requestTimeoutMs?: number;
379
+ /** Override per-category minimum intervals (ms). For tests only. */
380
+ intervals?: Partial<Record<EcpCategory, number>>;
381
+ /**
382
+ * Override `rk server` base URL. Precedence:
383
+ * `opts.serverUrl` → `RK_SERVER_URL` → `DEFAULT_RK_SERVER_URL`.
384
+ */
385
+ serverUrl?: string;
386
+ /** Test-only: inject a fake log session. Ignored by normal callers. */
387
+ logSession?: LogSessionLike;
388
+ }
389
+
390
+ /** Structural type for `LogSession`; kept here to avoid a types→class cycle. */
391
+ export interface LogSessionLike {
392
+ ensureStarted(): Promise<void>;
393
+ dispose(): void;
394
+ readonly buffer: LogBufferLike;
395
+ }
396
+
397
+ /** Structural type for `LogBuffer`'s public surface. */
398
+ export interface LogBufferLike {
399
+ append(line: LogLine): { seq: number; line: LogLine; ts: number };
400
+ currentPosition(): LogPosition;
401
+ linesSince(pos: LogPosition): LogLine[];
402
+ entriesSince(pos: LogPosition): Array<{ seq: number; line: LogLine; ts: number }>;
403
+ hasGapWithin(from: LogPosition, to?: LogPosition): { start: number; end: number } | null;
404
+ /**
405
+ * Return the tracked gap that contains `seq`, or `null`. Used by
406
+ * the per-match line-integrity check in `waitForLog`.
407
+ */
408
+ findGapContaining(seq: number): { start: number; end: number } | null;
409
+ subscribe(cb: (e: { seq: number; line: LogLine; ts: number }) => void): () => void;
410
+ markGapStart(): void;
411
+ markGapEnd(): void;
412
+ /** Lifetime stream-health stats. */
413
+ getStats(): {
414
+ gapCount: number;
415
+ totalGapSeqSpan: number;
416
+ lastGapAt: number | null;
417
+ openGap: boolean;
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Stream-health snapshot from `getLogStreamHealth()`. `serverMetrics`
423
+ * is `null` on server fetch failure, `undefined` when the server has
424
+ * no matching session, otherwise the matched session metrics.
425
+ */
426
+ export interface LogStreamHealth {
427
+ /** Closed gaps recorded on the client buffer since construction. */
428
+ gapCount: number;
429
+ /** Sum of `(end - start)` across closed client-side gaps (seq units). */
430
+ totalGapSeqSpan: number;
431
+ /** `Date.now()` of the most recent gap close, or `null` if none. */
432
+ lastGapAt: number | null;
433
+ /** Whether the client thinks a gap is currently open (reconnecting). */
434
+ openGap: boolean;
435
+ /** Server-side metrics; see class docstring for tri-state semantics. */
436
+ serverMetrics?: {
437
+ reconnectCount: number;
438
+ totalDowntime: number;
439
+ uptime: number;
440
+ connectedAt: string | null;
441
+ lastActivity: string | null;
442
+ } | null;
443
+ }