@roku-sdk/device-test-client 0.0.0-stage → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/docs/baselines.md +221 -0
  4. package/docs/configuration.md +258 -0
  5. package/docs/dom-assertions.md +264 -0
  6. package/docs/errors.md +525 -0
  7. package/docs/examples/deep-link-cold-start.md +85 -0
  8. package/docs/examples/focus-navigation.md +81 -0
  9. package/docs/examples/log-driven-assertion.md +99 -0
  10. package/docs/examples/precondition-skips.md +150 -0
  11. package/docs/examples/screenshot-regression.md +103 -0
  12. package/docs/examples/smoke-test.md +69 -0
  13. package/docs/getting-started.md +195 -0
  14. package/docs/jest-integration.md +331 -0
  15. package/docs/log-assertions.md +314 -0
  16. package/docs/preconditions.md +317 -0
  17. package/docs/rkclient.md +655 -0
  18. package/docs/screenshot-assertions.md +316 -0
  19. package/internal/constants.ts +54 -0
  20. package/internal/deploy.ts +60 -0
  21. package/internal/paths.ts +15 -0
  22. package/package.json +80 -3
  23. package/src/assertions/assertions.ts +661 -0
  24. package/src/assertions/baseline-env-guard.ts +43 -0
  25. package/src/assertions/baselines.ts +178 -0
  26. package/src/assertions/screenshot-artifacts.ts +39 -0
  27. package/src/assertions/screenshot-diff.ts +447 -0
  28. package/src/client/build-cache.ts +292 -0
  29. package/src/client/client.ts +1656 -0
  30. package/src/client/deploy.ts +402 -0
  31. package/src/client/ecp-queue.ts +112 -0
  32. package/src/client/ecp-retry.ts +94 -0
  33. package/src/client/factory.ts +147 -0
  34. package/src/client/health.ts +214 -0
  35. package/src/client/parsers.ts +155 -0
  36. package/src/client/server-url.ts +137 -0
  37. package/src/constants.ts +471 -0
  38. package/src/device/device-class.ts +57 -0
  39. package/src/device/device-env-sync.ts +58 -0
  40. package/src/device/device-resolver-sync.ts +43 -0
  41. package/src/device/device-resolver.ts +141 -0
  42. package/src/device/lease-provisioning.ts +54 -0
  43. package/src/errors.ts +773 -0
  44. package/src/index.ts +186 -0
  45. package/src/internal/constants.ts +3 -0
  46. package/src/jest/artifacts.ts +87 -0
  47. package/src/jest/device-test-environment.ts +420 -0
  48. package/src/jest/failure-artifacts.ts +169 -0
  49. package/src/jest/global-setup.ts +133 -0
  50. package/src/jest/global-teardown.ts +132 -0
  51. package/src/jest/install-hooks.ts +488 -0
  52. package/src/jest/jest-setup.ts +53 -0
  53. package/src/jest/preconditions.ts +239 -0
  54. package/src/jest/telemetry.ts +331 -0
  55. package/src/logs/device-log-sink.ts +69 -0
  56. package/src/logs/log-buffer.ts +265 -0
  57. package/src/logs/log-session.ts +374 -0
  58. package/src/logs/log-stream-client.ts +296 -0
  59. package/src/paths.ts +65 -0
  60. package/src/types.ts +443 -0
@@ -0,0 +1,655 @@
1
+ # `RkClient` reference
2
+
3
+ `RkClient` is the single entry point for ECP communication with a Roku
4
+ device. It serialises every request through a queue with per-category
5
+ minimum intervals, retries transient 5xx / timeout failures exactly
6
+ once, and layers readiness / health / polling / log semantics on top of
7
+ the raw ECP surface.
8
+
9
+ - [Construction](#construction)
10
+ - [ECP queries](#ecp-queries)
11
+ - [Input](#input)
12
+ - [Lifecycle (launch / sideload / deep-link)](#lifecycle)
13
+ - [Readiness & health](#readiness--health)
14
+ - [Polling helpers](#polling-helpers)
15
+ - [Log access](#log-access)
16
+ - [Parsers](#parsers)
17
+ - [Rate limits & retries](#rate-limits--retries)
18
+ - [Errors thrown](#errors-thrown)
19
+
20
+ ## Construction
21
+
22
+ ### `RkClient`
23
+
24
+ ```ts
25
+ class RkClient {
26
+ readonly deviceIp: string;
27
+ readonly keyDelay: number;
28
+
29
+ constructor(opts: RkClientOptions);
30
+
31
+ getServerUrl(): string;
32
+ }
33
+ ```
34
+
35
+ ```ts
36
+ interface RkClientOptions {
37
+ /** IPv4 of the target device. REQUIRED for `new RkClient`. */
38
+ deviceIp?: string;
39
+ /** Inter-key delay for `keysequence` (ms). Default `100`. */
40
+ keyDelay?: number;
41
+ /** Per-request ECP timeout in ms (captures use their own longer timeout). Default `5000`. */
42
+ requestTimeoutMs?: number;
43
+ /** Override per-category minimum queue intervals (ms). For tests only. */
44
+ intervals?: Partial<Record<EcpCategory, number>>;
45
+ /** `rk server` base URL. Resolution: opts → `RK_SERVER_URL` → `.roku/rk-server.json` → `http://localhost:3737/api`. Normalized to end with `/api` (no trailing slash) regardless of source. */
46
+ serverUrl?: string;
47
+ /** Pre-built `LogSessionLike` — reserved for tests, ignored by normal callers. */
48
+ logSession?: LogSessionLike;
49
+ }
50
+
51
+ type EcpCategory =
52
+ | "query-appui"
53
+ | "query-device-info"
54
+ | "query-active-app"
55
+ | "keypress"
56
+ | "launch"
57
+ | "capture";
58
+ ```
59
+
60
+ **Example — direct construction.**
61
+
62
+ ```ts
63
+ import { RkClient } from "@roku-sdk/device-test-client";
64
+
65
+ const client = new RkClient({
66
+ deviceIp: "192.168.1.42",
67
+ keyDelay: 150
68
+ });
69
+ ```
70
+
71
+ Call `new RkClient({})` without `deviceIp` to see `RkClientError(RK_DEVICE_IP_UNRESOLVED)`
72
+ — the error message tells you to use `createDefaultClient()` instead.
73
+
74
+ ### `createDefaultClient`
75
+
76
+ ```ts
77
+ function createDefaultClient(opts?: RkClientOptions): RkClient;
78
+ ```
79
+
80
+ Single source of truth for env-driven construction. Resolves `deviceIp`
81
+ from `opts.deviceIp` → `RK_DEVICE_IP` → `test-results/.device-ip`
82
+ (written by `globalSetup`). If `test-results/device-env.json` already
83
+ exists, the cache is seeded so `client.cachedDeviceInfo()` returns
84
+ immediately.
85
+
86
+ **Example — worker-side wiring.**
87
+
88
+ ```ts
89
+ import { createDefaultClient } from "@roku-sdk/device-test-client";
90
+
91
+ const client = createDefaultClient(); // Uses globalSetup-resolved IP.
92
+ const client2 = createDefaultClient({ keyDelay: 200 });
93
+ ```
94
+
95
+ **Common pitfall.** `createDefaultClient` is synchronous. If you construct
96
+ it from a host-side script that has not yet run `globalSetup` and has no
97
+ `RK_DEVICE_IP`, the call throws. In `globalSetup` itself, pass
98
+ `deviceIp` explicitly (see `createGlobalSetup` in
99
+ `[jest-integration.md](jest-integration.md)`).
100
+
101
+ ---
102
+
103
+ ## ECP queries
104
+
105
+ ### `queryAppUi`
106
+
107
+ ```ts
108
+ queryAppUi(): Promise<AppUiNode>;
109
+ ```
110
+
111
+ ```ts
112
+ interface AppUiNode {
113
+ tag: string;
114
+ attrs: Record<string, string>;
115
+ bounds: { x: number; y: number; width: number; height: number };
116
+ focused: boolean;
117
+ children: AppUiNode[];
118
+ }
119
+ ```
120
+
121
+ Fetches the full SceneGraph tree via `/query/app-ui`. Rate-limited at
122
+ 200 ms between successful calls. The ECP `name=` attribute is mirrored
123
+ into `attrs.id` at parse time so predicates can use the canonical `id`
124
+ selector (see [DOM assertions](dom-assertions.md#id--name-alias)).
125
+
126
+ **Example.**
127
+
128
+ ```ts
129
+ const tree = await client.queryAppUi();
130
+ console.log(tree.tag); // typically "app-ui"
131
+ console.log(tree.children.length);
132
+ ```
133
+
134
+ ### `queryDeviceInfo` / `cachedDeviceInfo` / `seedDeviceInfo`
135
+
136
+ ```ts
137
+ queryDeviceInfo(): Promise<DeviceInfo>;
138
+ cachedDeviceInfo(): DeviceInfo | null;
139
+ seedDeviceInfo(info: DeviceInfo): void;
140
+ resetCache(): void;
141
+ ```
142
+
143
+ ```ts
144
+ interface DeviceInfo {
145
+ modelName: string;
146
+ modelNumber: string;
147
+ modelFamily: string;
148
+ deviceClass: string; // form-factor: "tv" | "stick" | "player"
149
+ firmwareVersion: string;
150
+ videoMode: string; // e.g. "1080p60"
151
+ locale: string;
152
+ country: string;
153
+ libraries: Record<string, string>;
154
+ }
155
+ ```
156
+
157
+ `queryDeviceInfo` always hits the wire (ECP `/query/device-info`) and
158
+ caches the result. `cachedDeviceInfo` is free and returns `null` before
159
+ the first query. `seedDeviceInfo` primes the cache from an external
160
+ source (what `createDefaultClient` does with `device-env.json`).
161
+
162
+ **Example — cache-aware read.**
163
+
164
+ ```ts
165
+ const info = client.cachedDeviceInfo() ?? (await client.queryDeviceInfo());
166
+ if (info.videoMode.startsWith("1080p")) {
167
+ // use full-HD baselines
168
+ }
169
+ ```
170
+
171
+ ### `queryActiveApp`
172
+
173
+ ```ts
174
+ queryActiveApp(): Promise<ActiveAppInfo>;
175
+
176
+ interface ActiveAppInfo {
177
+ id: string; // "dev" for sideloaded, channel id for published, "" on home
178
+ title: string; // "Roku" on home
179
+ type: string; // e.g. "appl"
180
+ }
181
+ ```
182
+
183
+ **Example.**
184
+
185
+ ```ts
186
+ const active = await client.queryActiveApp();
187
+ expect(active.id).toBe("dev");
188
+ ```
189
+
190
+ ### `captureScreenshot`
191
+
192
+ ```ts
193
+ captureScreenshot(): Promise<Buffer>;
194
+ ```
195
+
196
+ Raw PNG bytes from `/capture-screen/png`. Rate-limited at 500 ms between
197
+ captures. For meaningful pixel comparisons, use
198
+ `[captureStableScreenshot](#capturestablescreenshot)` which polls until
199
+ two successive frames are identical.
200
+
201
+ **Example.**
202
+
203
+ ```ts
204
+ import { writeFileSync } from "node:fs";
205
+ writeFileSync("debug.png", await client.captureScreenshot());
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Input
211
+
212
+ ### `keypress`
213
+
214
+ ```ts
215
+ keypress(key: string): Promise<void>;
216
+ ```
217
+
218
+ Sends exactly one ECP keypress. The key name is validated before the
219
+ queue is entered — typos throw `RkClientError(ECP_INVALID_KEY)`
220
+ immediately. Accepts any Roku key (`"Up"`, `"Down"`, `"Select"`,
221
+ `"Home"`, `"Back"`, etc.).
222
+
223
+ **Example.**
224
+
225
+ ```ts
226
+ await client.keypress("Select");
227
+ ```
228
+
229
+ ### `keysequence`
230
+
231
+ ```ts
232
+ keysequence(keys: string[], opts?: KeySequenceOptions): Promise<void>;
233
+
234
+ interface KeySequenceOptions {
235
+ /** Inter-key delay (ms) between the end of one keypress and the start of the next. Defaults to `client.keyDelay`. */
236
+ keyDelay?: number;
237
+ }
238
+ ```
239
+
240
+ Validates every key up-front so a typo at index 7 fails before index 0
241
+ goes out. The delay is applied between keys — not before the first or
242
+ after the last.
243
+
244
+ **Example.**
245
+
246
+ ```ts
247
+ await client.keysequence(["Down", "Down", "Right", "Select"]);
248
+ await client.keysequence(["Up", "Up"], { keyDelay: 250 });
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Lifecycle
254
+
255
+ ### `launch`
256
+
257
+ ```ts
258
+ launch(opts?: LaunchOptions): Promise<void>;
259
+
260
+ interface LaunchOptions {
261
+ /** ECP channel id. Defaults to `"dev"`. */
262
+ channelId?: string;
263
+ /** Deep-link params for an **already-running** channel (reliable path). */
264
+ deepLink?: Record<string, string>;
265
+ /** Readiness gate timeout (ms). Defaults to 10_000. */
266
+ readinessTimeout?: number;
267
+ /** Semantic readiness predicate (see `BuildAndDeployOptions.readyNode`). */
268
+ readyNode?: NodePredicate;
269
+ }
270
+ ```
271
+
272
+ Launches a channel via `/launch/{channelId}`, then blocks on the shared
273
+ readiness + dialog gate. `launch` is the **reliable** path for
274
+ deep-link delivery when the channel is already installed — use it from
275
+ `beforeEach` to return the app to a known state between tests. For
276
+ cold sideloads that must land deep-linked, use
277
+ `[buildAndLaunchWithParams](#buildandlaunchwithparams)`.
278
+
279
+ **Example — re-open to a specific state.**
280
+
281
+ ```ts
282
+ beforeEach(async () => {
283
+ await client.launch({
284
+ deepLink: { contentId: "home" },
285
+ readyNode: { attr: { id: "home-root" } }
286
+ });
287
+ });
288
+ ```
289
+
290
+ **Auto-launch tolerance.** For a plain `dev` launch (no `deepLink`) the
291
+ POST races the launch the firmware already started for the sideloaded
292
+ build, so its verdict is not authoritative: a timeout, a `fetch failed`
293
+ blip, a 5xx from the busy launcher (`503 Service Unavailable` on
294
+ virtualized hosts), or a 404 from firmware that does not serve `/launch/dev` at all
295
+ are all tolerated, and the readiness gate decides. Any other `launch`
296
+ call — a named `channelId`, or one carrying `deepLink` params — treats
297
+ the POST status as final, because nothing else can prove those params
298
+ were delivered. `[buildAndLaunchWithParams](#buildandlaunchwithparams)`
299
+ is the one exception: its params POST tolerates the same race errors
300
+ (not a 404) because its mandatory params-ack log proves delivery.
301
+
302
+ ### `buildAndDeploy`
303
+
304
+ ```ts
305
+ buildAndDeploy(appPath: string, opts?: BuildAndDeployOptions): Promise<void>;
306
+
307
+ interface BuildAndDeployOptions {
308
+ deepLink?: Record<string, string>; // see "Common pitfall" below
309
+ readinessTimeout?: number; // ms, default 10_000
310
+ channelId?: string; // default "dev"
311
+ deviceSelector?: string; // override .roku/ active device
312
+ readyNode?: NodePredicate; // semantic readiness gate
313
+ }
314
+ ```
315
+
316
+ Builds the app at `appPath` (expects a `roku-config.ts` next to it),
317
+ sideloads the pkg-dir, launches, and blocks until the app is
318
+ interactive. Host-side build failures throw `BuildFailedError` (the
319
+ device is not marked unhealthy — the next test may succeed after a
320
+ code fix). Device-side failures throw `DeployFailedError` and flip the
321
+ health flag so `beforeEach` in the next test aborts fast.
322
+
323
+ **Minimal example.**
324
+
325
+ ```ts
326
+ await client.buildAndDeploy("apps/smoke");
327
+ ```
328
+
329
+ **Realistic example.**
330
+
331
+ ```ts
332
+ await client.buildAndDeploy("apps/grid", {
333
+ readyNode: { attr: { id: "grid-title", text: "Featured" } },
334
+ readinessTimeout: 20_000
335
+ });
336
+ ```
337
+
338
+ **Common pitfall — cold-sideload deep-link race.** `buildAndDeploy`'s
339
+ `deepLink` option is best-effort: on a fresh sideload the Install ECP
340
+ endpoint auto-launches without params, and the follow-up launch-with-
341
+ params POST frequently arrives before the app's
342
+ `setLaunchParamsUpdateHandler` has registered. Firmware drops it
343
+ silently. Use `buildAndLaunchWithParams` below when correctness
344
+ depends on a specific deep-linked landing state.
345
+
346
+ ### `buildAndLaunchWithParams`
347
+
348
+ ```ts
349
+ buildAndLaunchWithParams(
350
+ appPath: string,
351
+ deepLink: Record<string, string>,
352
+ opts: BuildAndLaunchWithParamsOptions
353
+ ): Promise<void>;
354
+
355
+ interface BuildAndLaunchWithParamsOptions {
356
+ deviceSelector?: string;
357
+ channelId?: string;
358
+ readinessTimeout?: number;
359
+ readyNode?: NodePredicate;
360
+ /** Required. Regex the app emits once its launch-params handler is live. */
361
+ runtimeReadyLogPattern: RegExp;
362
+ runtimeReadyTimeout?: number; // ms, default 5_000
363
+ /** Required. Regex the app emits once params propagate through the reactive graph. */
364
+ paramsAckLogPattern: RegExp;
365
+ paramsAckTimeout?: number; // ms, default 5_000
366
+ }
367
+ ```
368
+
369
+ Implements the three-step dance the cold-sideload race requires:
370
+
371
+ 1. sideload + auto-launch without params,
372
+ 2. `waitForLog(runtimeReadyLogPattern)` — proves the handler is live,
373
+ 3. `/launch/{channelId}?…` with params + `waitForLog(paramsAckLogPattern)` —
374
+ proves the reactive graph observed them.
375
+
376
+ Both log patterns are **required** — type-system enforcement that the
377
+ caller thought about observability.
378
+
379
+ **Example.**
380
+
381
+ ```ts
382
+ await client.buildAndLaunchWithParams(
383
+ "apps/player",
384
+ { contentId: "abc-123", startPos: "45" },
385
+ {
386
+ readyNode: { attr: { id: "player-ready" } },
387
+ runtimeReadyLogPattern: /launch-params-handler: ready/,
388
+ paramsAckLogPattern: /launch-params: contentId=abc-123/
389
+ }
390
+ );
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Readiness & health
396
+
397
+ ### `assertAppAlive`
398
+
399
+ ```ts
400
+ assertAppAlive(context?: unknown): Promise<void>;
401
+ ```
402
+
403
+ Single-shot "is the app still on screen?" probe. Throws
404
+ `AppCrashedError` if `/query/active-app` reports the home screen. Every
405
+ polling helper in the SDK (`waitForNode`, `waitForLog`,
406
+ `captureStableScreenshot`) invokes this between iterations so crashes
407
+ surface immediately instead of consuming the full timeout.
408
+
409
+ **Example.**
410
+
411
+ ```ts
412
+ await client.buildAndDeploy("apps/flaky");
413
+ // ... do some user interactions ...
414
+ await client.assertAppAlive(); // fail fast if the app crashed
415
+ ```
416
+
417
+ ### `detectSystemDialog`
418
+
419
+ ```ts
420
+ static detectSystemDialog(tree: AppUiNode): SystemDialogNode | null;
421
+ detectSystemDialog(tree: AppUiNode): SystemDialogNode | null;
422
+
423
+ interface SystemDialogNode {
424
+ tag: string; // e.g. "StandardMessageDialog"
425
+ title: string; // attrs.title if present
426
+ node: AppUiNode;
427
+ }
428
+ ```
429
+
430
+ Static and instance form of the same predicate — scans root-level
431
+ children for one of the known obstructing dialog tags (`Dialog`,
432
+ `StandardMessageDialog`, `StandardDialog`, `ProblemDialog`,
433
+ `KeyboardDialog`, `PinEntryDialog`). Returns the first match or `null`.
434
+
435
+ `buildAndDeploy` and `launch` call this automatically after readiness
436
+ and fail the deploy with `DeployFailedError` when a dialog is present.
437
+ Direct callers typically only need it for bespoke recovery flows.
438
+
439
+ **Example.**
440
+
441
+ ```ts
442
+ const tree = await client.queryAppUi();
443
+ const dialog = RkClient.detectSystemDialog(tree);
444
+ if (dialog) {
445
+ console.warn(`Obstructing dialog: ${dialog.tag} ("${dialog.title}")`);
446
+ }
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Polling helpers
452
+
453
+ ### `waitForNode`
454
+
455
+ ```ts
456
+ waitForNode(predicate: NodePredicate, opts: WaitOptions): Promise<AppUiNode>;
457
+
458
+ interface NodePredicate {
459
+ tag?: string;
460
+ attr?: Record<string, string>;
461
+ focused?: boolean;
462
+ }
463
+
464
+ interface WaitOptions {
465
+ timeout: number; // ms, required
466
+ interval?: number; // ms between iteration starts, default 200
467
+ }
468
+ ```
469
+
470
+ Polls `queryAppUi()` until `predicate` matches. Each iteration calls
471
+ `assertAppAlive` first, then fetches a fresh tree, then attempts the
472
+ match. `interval` is a **minimum** gap between iteration starts —
473
+ iterations that already took ≥ `interval` loop without extra sleep.
474
+
475
+ Throws `WaitForNodeTimeoutError` (with `context.lastTree` for
476
+ diagnostics) on timeout, or `AppCrashedError` if the app crashed
477
+ mid-wait.
478
+
479
+ **Example — first-render wait.**
480
+
481
+ ```ts
482
+ await client.waitForNode(
483
+ { attr: { id: "grid-item", text: "The Office" } },
484
+ { timeout: 10_000 }
485
+ );
486
+ ```
487
+
488
+ **Example — focus wait with custom interval.**
489
+
490
+ ```ts
491
+ await client.waitForNode(
492
+ { focused: true, attr: { id: "play-button" } },
493
+ { timeout: 3_000, interval: 100 }
494
+ );
495
+ ```
496
+
497
+ ### `captureStableScreenshot`
498
+
499
+ ```ts
500
+ captureStableScreenshot(opts: CaptureStableOptions): Promise<Buffer>;
501
+
502
+ interface CaptureStableOptions {
503
+ timeout: number;
504
+ interval?: number; // default 500 (matches the ECP queue gap)
505
+ mask?: MaskRegion[];
506
+ }
507
+
508
+ interface MaskRegion {
509
+ x: number;
510
+ y: number;
511
+ width: number;
512
+ height: number;
513
+ }
514
+ ```
515
+
516
+ Polls until two successive captures are pixel-identical outside any
517
+ `mask` regions, then returns the **newer** PNG. Calls `assertAppAlive`
518
+ before each capture. On timeout throws `ScreenshotNotStableError` with
519
+ `iterations`, `lastDiffCount`, `firstDiffPoint`, `maskedRegions`, and
520
+ `lastPng`.
521
+
522
+ **Example.**
523
+
524
+ ```ts
525
+ const png = await client.captureStableScreenshot({
526
+ timeout: 5_000,
527
+ mask: [{ x: 0, y: 0, width: 1920, height: 80 }] // blank the top bar
528
+ });
529
+ ```
530
+
531
+ See [Screenshot assertions](screenshot-assertions.md) for the
532
+ `expectScreenshot` counterpart.
533
+
534
+ ---
535
+
536
+ ## Log access
537
+
538
+ The log-assertion API is fully documented in
539
+ [log-assertions.md](log-assertions.md). The method set exposed
540
+ directly on `RkClient`:
541
+
542
+ ```ts
543
+ ensureLogSession(): Promise<void>;
544
+ disposeLogSession(): void;
545
+ logBuffer(): LogBufferLike;
546
+ recentLogLines(n?: number): Array<{ seq: number; line: LogLine }>;
547
+
548
+ logPosition(): LogPosition;
549
+ logs(opts: { since: LogPosition }): Promise<LogLine[]>;
550
+ waitForLog(pattern: RegExp, opts: LogQueryOptions): Promise<LogLine>;
551
+ expectNoLog(pattern: RegExp, opts: LogQueryOptions): Promise<void>;
552
+
553
+ interface LogQueryOptions {
554
+ since: LogPosition;
555
+ timeout?: number; // for waitForLog
556
+ window?: number; // for expectNoLog (alias: timeout)
557
+ }
558
+
559
+ type LogPosition = string; // opaque decimal seq
560
+ type LogLine = string;
561
+ ```
562
+
563
+ **Causality example** — the canonical pattern for proving a keypress
564
+ caused a log line to appear:
565
+
566
+ ```ts
567
+ const pos = client.logPosition(); // SYNC — take baseline
568
+ await client.keypress("Select");
569
+ const line = await client.waitForLog(/select-pressed/, {
570
+ since: pos,
571
+ timeout: 2_000
572
+ });
573
+ ```
574
+
575
+ **Common pitfall — `logPosition` must come before the causing action.**
576
+ Grabbing it after `keypress` means a fast-emitting log line can land
577
+ while `await keypress` is still in flight, making the `since` baseline
578
+ skip past it. The method is synchronous precisely so you can capture
579
+ it without a microtask boundary.
580
+
581
+ ---
582
+
583
+ ## Parsers (internal / deep-import only)
584
+
585
+ `parseBounds(raw)` and `parseDeviceInfo(raw)` are used internally by
586
+ `RkClient.queryAppUi` and `RkClient.queryDeviceInfo` to turn Roku's
587
+ wire format into the typed shapes this page documents. They were
588
+ exported from the package root in v1.x but were **removed from the
589
+ barrel** in v2.0.0 — normal consumers get the parsed values directly
590
+ from the client methods (`(await client.queryAppUi()).bounds`,
591
+ `await client.queryDeviceInfo()`), which is what the tests in this
592
+ repository already do.
593
+
594
+ Reporters that post-process raw `/query/device-info` XML without
595
+ going through an `RkClient` instance can still reach them as
596
+ deep imports:
597
+
598
+ ```ts
599
+ // DEEP IMPORT — not covered by SemVer.
600
+ import { parseBounds, parseDeviceInfo } from "@roku-sdk/device-test-client/src/client/parsers";
601
+ ```
602
+
603
+ If your consumer needs either on a stable public path, open an issue
604
+ so we can promote a dedicated export.
605
+
606
+ ---
607
+
608
+ ## Rate limits & retries
609
+
610
+
611
+ | Category | Interval (ms) | Notes |
612
+ | ------------------- | ------------- | -------------------------------------------------------- |
613
+ | `query-appui` | 200 | Matches the `waitForNode` polling cadence. |
614
+ | `query-device-info` | 0 | Cached after first fetch. |
615
+ | `query-active-app` | 0 | Crash probes fire on every polling tick. |
616
+ | `keypress` | 0 | Serialised via the queue; `keysequence` adds `keyDelay`. |
617
+ | `launch` | 0 | One-shot per test; pacing irrelevant. |
618
+ | `capture` | 500 | Expensive to render + encode server-side. |
619
+
620
+
621
+ All intervals are **gap-based** — measured between the *end* of one
622
+ call and the *start* of the next. Overridable for tests via
623
+ `RkClientOptions.intervals`.
624
+
625
+ **Retries.** Transient ECP failures (HTTP 5xx, network timeout, generic
626
+ `fetch failed`) are retried exactly once after a 500 ms backoff inside
627
+ the same queue slot. The second failure propagates verbatim as a
628
+ `ToolingError` (from `@roku-sdk/tooling`) so its `code` field stays
629
+ queryable — the SDK does not silently wrap it into an `RkClientError`.
630
+
631
+ ---
632
+
633
+ ## Errors thrown
634
+
635
+ Symbols below are the **class names**; full definitions and recovery
636
+ advice live in `[errors.md](errors.md)`.
637
+
638
+ - Construction — `RkClientError(RK_DEVICE_IP_UNRESOLVED)`
639
+ - Query parsing — `RkClientError(ECP_APPUI_PARSE_FAILED)`,
640
+ `RkClientError(ECP_ACTIVEAPP_PARSE_FAILED)`
641
+ - Invalid key — `RkClientError(ECP_INVALID_KEY)`
642
+ - Build / deploy / launch — `BuildFailedError`, `DeployFailedError`,
643
+ `DeviceUnhealthyError` (cascade guard)
644
+ - Readiness / health — `AppCrashedError`,
645
+ `DeployFailedError({ reason: "readiness-timeout" | "never-left-home" | "crashed-after-ready" | "system-dialog" })`.
646
+ The `never-left-home` reason latches the device **fatal**
647
+ (`markDeviceFatal` / `isDeviceFatal` / `getFatalReason`) — an
648
+ unrecoverable-on-this-VM wedge that disables recovery and fast-skips
649
+ downstream deploys; see [errors.md](errors.md#fatal-device-wedge)
650
+ - Polling — `WaitForNodeTimeoutError`, `AppCrashedError`,
651
+ `ScreenshotNotStableError`
652
+ - Logs (see [log-assertions.md](log-assertions.md)) —
653
+ `WaitForLogTimeoutError`, `UnexpectedLogError`, `LogStreamGapError`,
654
+ `LogBufferOverflowError`, `LogSessionStartError`
655
+