@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/docs/errors.md ADDED
@@ -0,0 +1,525 @@
1
+ # Error reference
2
+
3
+ Every error class exported from `@roku-sdk/device-test-client` is
4
+ listed below with its declaration, context fields, typical causes,
5
+ and an `instanceof`-based narrowing example.
6
+
7
+ All errors extend `Error`, set a unique `.name` literal, and attach
8
+ structured context fields so CI reporters can render "id / title /
9
+ reason" columns without parsing the message string.
10
+
11
+ - [How to narrow errors](#how-to-narrow-errors)
12
+ - [RkClient / construction](#rkclient--construction)
13
+ - [Build / deploy](#build--deploy)
14
+ - [Lifecycle health](#lifecycle-health)
15
+ - [DOM assertions](#dom-assertions)
16
+ - [Screenshot assertions](#screenshot-assertions)
17
+ - [Log assertions](#log-assertions)
18
+ - [Preconditions](#preconditions)
19
+
20
+ ## How to narrow errors
21
+
22
+ Use `instanceof` — every error class is exported from the package
23
+ root:
24
+
25
+ ```ts
26
+ import {
27
+ WaitForNodeTimeoutError,
28
+ AppCrashedError
29
+ } from "@roku-sdk/device-test-client";
30
+
31
+ try {
32
+ await client.waitForNode({ attr: { id: "tile" } }, { timeout: 3_000 });
33
+ } catch (err) {
34
+ if (err instanceof AppCrashedError) {
35
+ console.log(`App crashed; active app: ${err.activeApp.title}`);
36
+ throw err;
37
+ }
38
+ if (err instanceof WaitForNodeTimeoutError) {
39
+ console.log(`Last tree had ${err.context.lastTree?.children.length} children`);
40
+ throw err;
41
+ }
42
+ throw err;
43
+ }
44
+ ```
45
+
46
+ ---
47
+
48
+ ## RkClient / construction
49
+
50
+ ### `RkClientError`
51
+
52
+ ```ts
53
+ class RkClientError extends Error {
54
+ readonly name: "RkClientError";
55
+ readonly code?: string;
56
+ }
57
+ ```
58
+
59
+ Thrown for framework-internal failures that are **not** wire /
60
+ transport errors. Known codes:
61
+
62
+
63
+ | code | Thrown by | Typical fix |
64
+ | ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
65
+ | `RK_DEVICE_IP_UNRESOLVED` | `new RkClient({})` with no IP; `resolveActiveDeviceIp` | Set `RK_DEVICE_IP` or `rk device add <ip>` |
66
+ | `ECP_INVALID_KEY` | `keypress`, `keysequence` | Use a valid Roku key name |
67
+ | `ECP_APPUI_PARSE_FAILED` | `queryAppUi` | Firmware bug / malformed XML; check `rk device ping` |
68
+ | `ECP_ACTIVEAPP_PARSE_FAILED` | `queryActiveApp` | Firmware bug / malformed XML |
69
+ | `PRECONDITION_NO_DEVICE_INFO` | `requirePrecondition` before cache populated | Call `installDeviceTestHooks` / `queryDeviceInfo` first |
70
+ | `PRECONDITION_INVALID_SEMVER_RANGE` | `requirePrecondition` / `itOnDevice` with bad range | Fix the semver range syntax |
71
+
72
+
73
+ Wire errors propagate as `ToolingError` from `@roku-sdk/tooling`
74
+ without wrapping — their `code` field stays queryable downstream.
75
+
76
+ ---
77
+
78
+ ## Build / deploy
79
+
80
+ ### `BuildFailedError`
81
+
82
+ ```ts
83
+ class BuildFailedError extends Error {
84
+ readonly name: "BuildFailedError";
85
+ readonly appPath: string;
86
+ readonly cause?: unknown;
87
+ }
88
+ ```
89
+
90
+ Thrown by `buildAndDeploy` when the host-side build (TypeScript
91
+ compile, manifest parse, bundler) fails. **Does NOT mark the device
92
+ unhealthy** — the next test can succeed after a code fix without
93
+ device intervention.
94
+
95
+ ```ts
96
+ try {
97
+ await client.buildAndDeploy("apps/broken");
98
+ } catch (err) {
99
+ if (err instanceof BuildFailedError) {
100
+ console.error(`Build failed for ${err.appPath}:`, err.cause);
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### `DeployFailedError`
106
+
107
+ ```ts
108
+ class DeployFailedError extends Error {
109
+ readonly name: "DeployFailedError";
110
+ readonly context: {
111
+ appPath?: string;
112
+ deviceIp?: string;
113
+ diagnostics?: string;
114
+ lastTree?: AppUiNode;
115
+ lastActiveApp?: ActiveAppInfo;
116
+ dialog?: SystemDialogNode;
117
+ phase?: "launch" | "readiness" | "sideload" | "config";
118
+ expectedChannelId?: string;
119
+ reason?: string;
120
+ cause?: unknown;
121
+ };
122
+ }
123
+ ```
124
+
125
+ Thrown by `buildAndDeploy` / `launch` / `buildAndLaunchWithParams`
126
+ when the device-side step fails. Every instance is paired with
127
+ `markDeviceUnhealthy` so the cascade guard aborts subsequent tests.
128
+
129
+ Known `context.reason` values: `"readiness-timeout"`,
130
+ `"never-left-home"`, `"crashed-after-ready"`, `"system-dialog"`,
131
+ `"sideload-rejected"`.
132
+
133
+ `"never-left-home"` is special. It means the device answered ECP for
134
+ the entire readiness budget but never foregrounded the sideloaded
135
+ channel — an **unrecoverable-on-this-VM** condition (a wedged
136
+ device host). It latches the device **fatal**
137
+ (`markDeviceFatal`), not merely unhealthy, so downstream deploys
138
+ fast-skip and the recovery `beforeEach` refuses to revive it. See
139
+ [Fatal device wedge](#fatal-device-wedge) below.
140
+
141
+ ---
142
+
143
+ ## Lifecycle health
144
+
145
+ ### `AppCrashedError`
146
+
147
+ ```ts
148
+ class AppCrashedError extends Error {
149
+ readonly name: "AppCrashedError";
150
+ readonly activeApp: ActiveAppInfo;
151
+ readonly context?: unknown;
152
+ }
153
+ ```
154
+
155
+ Thrown by `assertAppAlive` (and every polling helper that calls it)
156
+ when `/query/active-app` reports the home screen. `activeApp.title`
157
+ is `"Roku"` on the home screen.
158
+
159
+ ```ts
160
+ try {
161
+ await client.waitForNode(…);
162
+ } catch (err) {
163
+ if (err instanceof AppCrashedError) {
164
+ console.error("app crashed:", err.activeApp);
165
+ }
166
+ }
167
+ ```
168
+
169
+ ### `DeviceUnhealthyError`
170
+
171
+ ```ts
172
+ class DeviceUnhealthyError extends Error {
173
+ readonly name: "DeviceUnhealthyError";
174
+ readonly reason: string;
175
+ }
176
+ ```
177
+
178
+ Thrown by `installDeviceTestHooks`'s `beforeEach` when a previous
179
+ test flipped the health flag. Aborts the remaining tests in the
180
+ worker fast so reviewers aren't wading through 50 cascade failures.
181
+
182
+ ### Fatal device wedge
183
+
184
+ There are two device-health states, exposed as separate helper sets:
185
+
186
+ | Concept | Set | Reset by | Models |
187
+ |---------|-----|----------|--------|
188
+ | Soft health | `markDeviceUnhealthy` / `isDeviceHealthy` / `getUnhealthyReason` | `resetDeviceHealth()` | **Recoverable** fault (crash/reboot the device returns from). |
189
+ | Fatal latch | `markDeviceFatal` / `isDeviceFatal` / `getFatalReason` | *(process-lifetime; not cleared by `resetDeviceHealth`)* | **Unrecoverable-on-this-VM** fault — device answers ECP/installer but won't foreground a sideloaded channel. |
190
+
191
+ `markDeviceFatal` also flips the soft flag, so the existing cascade
192
+ keeps firing and you never need to call both. Currently only
193
+ `DeployFailedError({ reason: "never-left-home" })` latches fatal.
194
+
195
+ Consequences once fatal is latched (single `--runInBand` process):
196
+
197
+ - The recovery `beforeEach` in `installDeviceTestHooks` does **not**
198
+ attempt `waitForDeviceOnline` — a wedged VM still answers it, which
199
+ would falsely "recover" and re-run the test. It throws
200
+ `DeviceUnhealthyError` immediately with a "recycle the instance"
201
+ message.
202
+ - `installDeployGuard` (in `device-tests/`) short-circuits: remaining
203
+ suites skip their deploy entirely instead of burning the full
204
+ retry × backoff budget on a dead instance. The device host
205
+ must be recycled at the lease/runner layer.
206
+
207
+ ---
208
+
209
+ ## DOM assertions
210
+
211
+ ### `NodeNotFoundError`
212
+
213
+ ```ts
214
+ class NodeNotFoundError extends Error {
215
+ readonly name: "NodeNotFoundError";
216
+ readonly predicate: NodePredicate;
217
+ readonly treeSummary: string; // truncated pseudo-XML listing
218
+ }
219
+ ```
220
+
221
+ Thrown by `findNode` / `findFocusedNode` / `expectFocused`. The tree
222
+ summary is truncated to the first 3 levels / 2 000 chars so the
223
+ reporter stays scannable.
224
+
225
+ ### `FocusedNodeMismatchError`
226
+
227
+ ```ts
228
+ class FocusedNodeMismatchError extends Error {
229
+ readonly name: "FocusedNodeMismatchError";
230
+ readonly expectedPredicate: NodePredicate;
231
+ readonly actualFocusedNode: string; // one-line summary of the focused node
232
+ }
233
+ ```
234
+
235
+ Thrown by `expectFocused` when a node IS focused but doesn't match
236
+ the predicate.
237
+
238
+ ### `WaitForNodeTimeoutError`
239
+
240
+ ```ts
241
+ class WaitForNodeTimeoutError extends Error {
242
+ readonly name: "WaitForNodeTimeoutError";
243
+ readonly context: {
244
+ predicate: NodePredicate;
245
+ timeoutMs: number;
246
+ lastTree: AppUiNode | null;
247
+ };
248
+ }
249
+ ```
250
+
251
+ Thrown by `client.waitForNode` on timeout. `context.lastTree` is the
252
+ final snapshot the polling loop observed.
253
+
254
+ ### `BaselineMissingError`
255
+
256
+ ```ts
257
+ class BaselineMissingError extends Error {
258
+ readonly name: "BaselineMissingError";
259
+ readonly path: string; // absolute baseline path
260
+ readonly resolution: string; // "hd" | "fhd"
261
+ readonly videoMode: string | undefined;
262
+ }
263
+ ```
264
+
265
+ Thrown by `expectTree(…).toMatchBaseline(path)` when the file does
266
+ not exist and `UPDATE_BASELINES=1` was not set.
267
+
268
+ ### `BaselineResolutionError`
269
+
270
+ ```ts
271
+ class BaselineResolutionError extends Error {
272
+ readonly name: "BaselineResolutionError";
273
+ readonly context: {
274
+ videoMode?: string;
275
+ reason: "unsupported-videoMode" | "no-active-client-and-no-env" | "invalid-env-value";
276
+ };
277
+ }
278
+ ```
279
+
280
+ Thrown when `hd` / `fhd` subdirectory cannot be determined. See
281
+ [baselines.md](baselines.md#resolution-routing) for the precedence
282
+ table.
283
+
284
+ ### `TreeMismatchError`
285
+
286
+ ```ts
287
+ class TreeMismatchError extends Error {
288
+ readonly name: "TreeMismatchError";
289
+ readonly context: {
290
+ baselinePath: string;
291
+ firstDiff: string; // JSONPath (e.g. "$.children[0].attrs.opacity")
292
+ expectedSnippet: string; // truncated baseline snippet
293
+ actualSnippet: string; // truncated actual snippet
294
+ };
295
+ }
296
+ ```
297
+
298
+ Thrown by `expectTree(…).toMatchBaseline(…)` when the tree diverges
299
+ from the baseline.
300
+
301
+ ---
302
+
303
+ ## Screenshot assertions
304
+
305
+ ### `ScreenshotNotStableError`
306
+
307
+ ```ts
308
+ class ScreenshotNotStableError extends Error {
309
+ readonly name: "ScreenshotNotStableError";
310
+ readonly timeoutMs: number;
311
+ readonly iterations: number;
312
+ readonly lastDiffCount: number;
313
+ readonly maskedRegions: MaskRegion[];
314
+ readonly firstDiffPoint?: { x: number; y: number };
315
+ readonly lastPng?: Buffer;
316
+
317
+ toJSON(): Record<string, unknown>; // omits lastPng
318
+ }
319
+ ```
320
+
321
+ Thrown by `captureStableScreenshot` on timeout. `lastPng` is the most
322
+ recent capture; `toJSON` explicitly omits it so
323
+ `JSON.stringify(err)` doesn't balloon the CI log.
324
+
325
+ ### `ScreenshotBaselineMissingError`
326
+
327
+ ```ts
328
+ class ScreenshotBaselineMissingError extends Error {
329
+ readonly name: "ScreenshotBaselineMissingError";
330
+ readonly baselinePath: string;
331
+ readonly resolution: "hd" | "fhd";
332
+ }
333
+ ```
334
+
335
+ Thrown by `expectScreenshot(…).toMatchBaseline(…)` when the baseline
336
+ PNG does not exist and `UPDATE_BASELINES=1` was not set.
337
+
338
+ ### `ScreenshotDimensionsMismatchError`
339
+
340
+ ```ts
341
+ class ScreenshotDimensionsMismatchError extends Error {
342
+ readonly name: "ScreenshotDimensionsMismatchError";
343
+ readonly context: {
344
+ baselinePath: string;
345
+ resolution: "hd" | "fhd";
346
+ baseline: { w: number; h: number };
347
+ actual: { w: number; h: number };
348
+ };
349
+ }
350
+ ```
351
+
352
+ Thrown when baseline and actual PNGs have different sizes. No diff
353
+ PNG is written in this path.
354
+
355
+ ### `ScreenshotMismatchError`
356
+
357
+ ```ts
358
+ class ScreenshotMismatchError extends Error {
359
+ readonly name: "ScreenshotMismatchError";
360
+ readonly context: {
361
+ baselinePath: string;
362
+ resolution: "hd" | "fhd";
363
+ actual: { w: number; h: number };
364
+ mismatchedPixels: number;
365
+ totalPixels: number;
366
+ ratio: number;
367
+ threshold: number;
368
+ diffPath: string;
369
+ alignment?: { dx: number; dy: number };
370
+ };
371
+ }
372
+ ```
373
+
374
+ Thrown when the pixel-mismatch ratio exceeds the threshold. The diff
375
+ PNG is **always** written to `diffPath` before the error is thrown
376
+ — reporters that swallow the error can still attach it.
377
+
378
+ `alignment` appears only when an `alignTolerance` search found a
379
+ translation that scored better than the untranslated comparison, and
380
+ names the offset the reported `ratio` was measured at. A non-zero
381
+ `alignment` on a failure means the surface both moved and changed.
382
+
383
+ ### `ScreenshotMaskError`
384
+
385
+ ```ts
386
+ class ScreenshotMaskError extends Error {
387
+ readonly name: "ScreenshotMaskError";
388
+ readonly context: {
389
+ region: MaskRegion;
390
+ canvas: { w: number; h: number };
391
+ reason: "out-of-bounds" | "zero-area";
392
+ };
393
+ }
394
+ ```
395
+
396
+ Thrown for malformed `MaskRegion` values. Partial overlap is NOT an
397
+ error — those regions are clamped silently.
398
+
399
+ ### `ScreenshotAlignToleranceError`
400
+
401
+ ```ts
402
+ class ScreenshotAlignToleranceError extends Error {
403
+ readonly name: "ScreenshotAlignToleranceError";
404
+ readonly context: {
405
+ axis: "x" | "y";
406
+ value: number;
407
+ max: number;
408
+ reason: "not-an-integer" | "negative" | "over-cap";
409
+ };
410
+ }
411
+ ```
412
+
413
+ Thrown for a malformed `alignTolerance` axis. Unlike masks, nothing is
414
+ clamped — a bad value is a caller bug, and it is validated before the
415
+ comparison so it surfaces on passing runs too.
416
+
417
+ ---
418
+
419
+ ## Log assertions
420
+
421
+ ### `WaitForLogTimeoutError`
422
+
423
+ ```ts
424
+ class WaitForLogTimeoutError extends Error {
425
+ readonly name: "WaitForLogTimeoutError";
426
+ readonly pattern: string;
427
+ readonly since: LogPosition;
428
+ readonly timeoutMs: number;
429
+ readonly linesChecked: number;
430
+ readonly recentLines: LogLine[]; // last 20 entries, capped
431
+ }
432
+ ```
433
+
434
+ Thrown by `waitForLog` on timeout.
435
+
436
+ ### `UnexpectedLogError`
437
+
438
+ ```ts
439
+ class UnexpectedLogError extends Error {
440
+ readonly name: "UnexpectedLogError";
441
+ readonly pattern: string;
442
+ readonly matchedLine: LogLine;
443
+ readonly since: LogPosition;
444
+ }
445
+ ```
446
+
447
+ Thrown by `expectNoLog` when the forbidden pattern matched a line in
448
+ the window.
449
+
450
+ ### `LogStreamGapError`
451
+
452
+ ```ts
453
+ class LogStreamGapError extends Error {
454
+ readonly name: "LogStreamGapError";
455
+ readonly gapStart: LogPosition;
456
+ readonly gapEnd: LogPosition;
457
+ }
458
+ ```
459
+
460
+ Thrown by `waitForLog` / `expectNoLog` / `logs` when an SSE
461
+ reconnect gap overlaps the query range — the missing lines *might*
462
+ have matched, so we fail rather than silently pass.
463
+
464
+ ### `LogBufferOverflowError`
465
+
466
+ ```ts
467
+ class LogBufferOverflowError extends Error {
468
+ readonly name: "LogBufferOverflowError";
469
+ readonly requested: LogPosition;
470
+ readonly minAvailable: LogPosition;
471
+ }
472
+ ```
473
+
474
+ Thrown when a `since` position references lines the buffer has
475
+ already evicted. Fix: raise `LogBuffer` cap, take fresh
476
+ `logPosition()` more often, or shorten assertion windows.
477
+
478
+ ### `LogSessionStartError`
479
+
480
+ ```ts
481
+ class LogSessionStartError extends Error {
482
+ readonly name: "LogSessionStartError";
483
+ readonly serverUrl: string;
484
+ readonly statusCode?: number;
485
+ readonly detail?: string;
486
+ }
487
+ ```
488
+
489
+ Thrown when `POST /logs/start` fails with an unrecoverable HTTP error
490
+ or SSE attach fails before ready. A 400 "already active" response is
491
+ NOT a failure — the session attaches to the existing server-side
492
+ session.
493
+
494
+ ---
495
+
496
+ ## Preconditions
497
+
498
+ ### `PreconditionNotMetError`
499
+
500
+ ```ts
501
+ class PreconditionNotMetError extends Error {
502
+ readonly name: "PreconditionNotMetError";
503
+ readonly field: string;
504
+ readonly expected: string;
505
+ readonly actual: string;
506
+ readonly reason: "mismatch" | "missing-device-info" | "not-implemented";
507
+ }
508
+ ```
509
+
510
+ Thrown by `requirePrecondition`. Registration-time wrappers
511
+ (`itOnDevice` / `describeOnDevice`) catch this class and convert it
512
+ to `it.skip()` / `describe.skip()`.
513
+
514
+ ### `PreconditionNotImplementedError`
515
+
516
+ ```ts
517
+ class PreconditionNotImplementedError extends PreconditionNotMetError {
518
+ readonly name: "PreconditionNotImplementedError";
519
+ readonly feature: string; // e.g. "library-version"
520
+ }
521
+ ```
522
+
523
+ Extends `PreconditionNotMetError` so the skip-converter handles it
524
+ identically. Thrown when the caller asks for a field the SDK cannot
525
+ query (today: `library`).
@@ -0,0 +1,85 @@
1
+ # Example: deep-link cold start
2
+
3
+ `buildAndLaunchWithParams` deploys an app and immediately launches it
4
+ with ECP `contentId` / `mediaType` query parameters. The footgun: on a
5
+ cold sideload the launch parameters race with the channel's own
6
+ cold-start code path, so this example gates readiness on both a DOM
7
+ node AND a log pattern.
8
+
9
+ ## Prerequisites
10
+
11
+ - An app that logs a deterministic line once it has parsed the
12
+ launch params (e.g. `deep-link:parsed contentId=<id>`).
13
+ - A DOM node that appears only on the deep-linked screen
14
+ (e.g. `id="detail-screen"`).
15
+
16
+ ## Test file
17
+
18
+ ```ts
19
+ // tests/deep-link.test.ts
20
+ import {
21
+ installDeviceTestHooks,
22
+ findNode
23
+ } from "@roku-sdk/device-test-client";
24
+
25
+ const ctx = installDeviceTestHooks();
26
+
27
+ describe("deep link cold start", () => {
28
+ test("opens detail screen for the deep-linked content id", async () => {
29
+ const client = ctx.getClient();
30
+ const contentId = "movie-42";
31
+
32
+ const pos = client.logPosition();
33
+
34
+ await client.buildAndLaunchWithParams("apps/video", {
35
+ contentId,
36
+ mediaType: "movie",
37
+ readyNode: { attr: { id: "detail-screen" } },
38
+ readinessTimeout: 15_000
39
+ });
40
+
41
+ await client.waitForLog(
42
+ new RegExp(`deep-link:parsed contentId=${contentId}`),
43
+ { since: pos, timeout: 10_000 }
44
+ );
45
+
46
+ const tree = await client.queryAppUi();
47
+ const title = findNode(tree, {
48
+ tag: "Label",
49
+ attr: { id: "content-title" }
50
+ });
51
+ expect(title.attrs.text).toBeTruthy();
52
+ });
53
+ });
54
+ ```
55
+
56
+ ## Walkthrough
57
+
58
+ 1. **Snapshot the log position first.** `logPosition()` is O(1) and
59
+ cheap. We capture it *before* starting the deploy so the log
60
+ window covers the entire launch sequence. If we snapshotted after
61
+ `buildAndLaunchWithParams`, the parse line would likely be older
62
+ than `since`, and `waitForLog` would timeout even though the app
63
+ did the right thing.
64
+ 2. **Deploy + launch with readiness.** `readyNode` polls
65
+ `queryAppUi` until the detail screen renders. `readinessTimeout`
66
+ is bumped to 15 s for cold-start scenarios where bundle hydration
67
+ dominates.
68
+ 3. **Await the app-level ack.** Readiness says the node appeared;
69
+ the log gate says the app actually parsed and respected the
70
+ contentId. Both together rule out the "generic home screen with a
71
+ label that happens to match" failure mode.
72
+ 4. **Verify content.** Trivial DOM assertion once the gates pass.
73
+
74
+ ## Common pitfalls
75
+
76
+ - **Don't skip the `logPosition()` snapshot.** Every `waitForLog`
77
+ without `since` scans from the buffer's minimum retained line,
78
+ which on long-lived workers can be minutes old.
79
+ - **Use readiness that is exclusive to the deep link.** A generic
80
+ `id="main"` node is often present on the home screen too — the
81
+ test will go green before the app actually navigated.
82
+ - **If the app's log line is non-deterministic,** gate on DOM only
83
+ and add a second `waitForNode` for a deep-link-only sub-element
84
+ (e.g. a back button that only exists on detail screens).
85
+
@@ -0,0 +1,81 @@
1
+ # Example: focus navigation
2
+
3
+ End-to-end test that drives a grid with directional keys and asserts
4
+ the focused node after each step. Demonstrates the
5
+ `keysequence` + `waitForNode({ focused: true })` idiom.
6
+
7
+ ## Prerequisites
8
+
9
+ - An app with a navigable tile grid where each tile has a stable
10
+ `id` attribute (e.g. `tile-0`, `tile-1`, `tile-2`).
11
+
12
+ ## Test file
13
+
14
+ ```ts
15
+ // tests/focus-nav.test.ts
16
+ import {
17
+ installDeviceTestHooks,
18
+ expectFocused,
19
+ findFocusedNode
20
+ } from "@roku-sdk/device-test-client";
21
+
22
+ const ctx = installDeviceTestHooks();
23
+
24
+ describe("grid focus navigation", () => {
25
+ test("Right, Right, Down lands on tile-5", async () => {
26
+ const client = ctx.getClient();
27
+
28
+ await client.buildAndDeploy("apps/grid", {
29
+ readyNode: { attr: { id: "grid" } }
30
+ });
31
+
32
+ const initial = await client.queryAppUi();
33
+ expectFocused(initial, { attr: { id: "tile-0" } });
34
+
35
+ await client.keysequence(["Right", "Right"], { keyDelay: 200 });
36
+ const row1 = await client.waitForNode(
37
+ { focused: true, attr: { id: "tile-2" } },
38
+ { timeout: 2_000 }
39
+ );
40
+ expect(row1.attrs.id).toBe("tile-2");
41
+
42
+ await client.keypress("Down");
43
+ const focused = findFocusedNode(
44
+ await client.queryAppUi()
45
+ );
46
+ expect(focused?.attrs.id).toBe("tile-5");
47
+ });
48
+ });
49
+ ```
50
+
51
+ ## Walkthrough
52
+
53
+ 1. `expectFocused(tree, predicate)` is synchronous — it asserts on
54
+ an already-captured tree. Use it when the previous action
55
+ guaranteed the device is steady (e.g. just after
56
+ `buildAndDeploy` readiness).
57
+ 2. `keysequence(["Right", "Right"], { keyDelay: 200 })` sends two
58
+ keys with a 200 ms pause between them. Override the default
59
+ 100 ms (`DEFAULT_KEY_DELAY`) when your app's focus-change
60
+ animations are slow.
61
+ 3. `waitForNode({ focused: true, attr: { id: "tile-2" } })` polls
62
+ `queryAppUi` until BOTH the `focused: true` bit and the id
63
+ predicate match on the same node. This is more robust than
64
+ asserting each dimension separately because it tolerates the
65
+ frame where focus has moved off tile-0 but not yet landed on
66
+ tile-2.
67
+ 4. `findFocusedNode` returns `null` if nothing is focused — prefer
68
+ `expectFocused` when you want a thrown
69
+ `NodeNotFoundError` / `FocusedNodeMismatchError` instead.
70
+
71
+ ## Common pitfalls
72
+
73
+ - **Don't sleep between keypresses.** Use `keyDelay` or
74
+ `waitForNode({ focused: true, … })` rather than arbitrary
75
+ `setTimeout` — it's slower on fast devices and flaky on slow ones.
76
+ - **Assert node identity, not just "focused: true".** A test that
77
+ only checks `{ focused: true }` passes as long as *some* node has
78
+ focus, even if it's the wrong one.
79
+ - **Beware RTL apps.** `Right` / `Left` key semantics flip; prefer
80
+ `FocusChange` log lines when targeting locales with RTL layout.
81
+