@roku-sdk/device-test-client 0.0.0-stage → 2.19.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 (61) hide show
  1. package/BREAKING_CHANGES.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -2
  4. package/docs/baselines.md +221 -0
  5. package/docs/configuration.md +258 -0
  6. package/docs/dom-assertions.md +264 -0
  7. package/docs/errors.md +544 -0
  8. package/docs/examples/deep-link-cold-start.md +85 -0
  9. package/docs/examples/focus-navigation.md +81 -0
  10. package/docs/examples/log-driven-assertion.md +99 -0
  11. package/docs/examples/precondition-skips.md +150 -0
  12. package/docs/examples/screenshot-regression.md +103 -0
  13. package/docs/examples/smoke-test.md +69 -0
  14. package/docs/getting-started.md +195 -0
  15. package/docs/jest-integration.md +331 -0
  16. package/docs/log-assertions.md +349 -0
  17. package/docs/preconditions.md +317 -0
  18. package/docs/rkclient.md +657 -0
  19. package/docs/screenshot-assertions.md +316 -0
  20. package/package.json +81 -3
  21. package/src/assertions/assertions.ts +661 -0
  22. package/src/assertions/baseline-env-guard.ts +43 -0
  23. package/src/assertions/baselines.ts +178 -0
  24. package/src/assertions/screenshot-artifacts.ts +39 -0
  25. package/src/assertions/screenshot-diff.ts +447 -0
  26. package/src/client/build-cache.ts +292 -0
  27. package/src/client/client.ts +1964 -0
  28. package/src/client/deploy.ts +407 -0
  29. package/src/client/ecp-queue.ts +112 -0
  30. package/src/client/ecp-retry.ts +94 -0
  31. package/src/client/factory.ts +170 -0
  32. package/src/client/health.ts +345 -0
  33. package/src/client/parsers.ts +162 -0
  34. package/src/client/server-url.ts +137 -0
  35. package/src/constants.ts +520 -0
  36. package/src/device/device-class.ts +57 -0
  37. package/src/device/device-env-sync.ts +58 -0
  38. package/src/device/device-resolver-sync.ts +43 -0
  39. package/src/device/device-resolver.ts +141 -0
  40. package/src/device/lease-provisioning.ts +54 -0
  41. package/src/errors.ts +774 -0
  42. package/src/index.ts +181 -0
  43. package/src/internal/constants.ts +3 -0
  44. package/src/jest/artifacts.ts +87 -0
  45. package/src/jest/device-test-environment.ts +420 -0
  46. package/src/jest/failure-artifacts.ts +169 -0
  47. package/src/jest/global-setup.ts +137 -0
  48. package/src/jest/global-teardown.ts +132 -0
  49. package/src/jest/install-hooks.ts +490 -0
  50. package/src/jest/jest-setup.ts +53 -0
  51. package/src/jest/preconditions.ts +239 -0
  52. package/src/jest/telemetry.ts +331 -0
  53. package/src/logs/device-log-sink.ts +69 -0
  54. package/src/logs/log-buffer.ts +278 -0
  55. package/src/logs/log-session.ts +436 -0
  56. package/src/logs/log-stream-client.ts +296 -0
  57. package/src/paths.ts +65 -0
  58. package/src/types.ts +472 -0
  59. package/support/constants.ts +17 -0
  60. package/support/deploy.ts +27 -0
  61. package/support/paths.ts +13 -0
package/docs/errors.md ADDED
@@ -0,0 +1,544 @@
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. On a device that has not been seen foregrounding a channel
136
+ at all, that is an **unrecoverable-on-this-VM** condition (a wedged
137
+ device host): it latches the device **fatal** (`markDeviceFatal`),
138
+ not merely unhealthy, so downstream deploys fast-skip and the
139
+ recovery `beforeEach` refuses to revive it. See
140
+ [Fatal device wedge](#fatal-device-wedge) below.
141
+
142
+ Once any deploy has put a channel on screen, the same observation
143
+ carries the softer meaning — a launch slower than the budget looks
144
+ identical from inside one deploy — so it only flips soft health and
145
+ the deploy-guard retries that deploy. The `reason` tag reports what
146
+ was observed either way; only the verdict differs.
147
+
148
+ ---
149
+
150
+ ## Lifecycle health
151
+
152
+ ### `AppCrashedError`
153
+
154
+ ```ts
155
+ class AppCrashedError extends Error {
156
+ readonly name: "AppCrashedError";
157
+ readonly activeApp: ActiveAppInfo;
158
+ readonly context?: unknown;
159
+ }
160
+ ```
161
+
162
+ Thrown by `assertAppAlive` (and every polling helper that calls it)
163
+ when `/query/active-app` reports the home screen. `activeApp.title`
164
+ is `"Roku"` on the home screen.
165
+
166
+ ```ts
167
+ try {
168
+ await client.waitForNode(…);
169
+ } catch (err) {
170
+ if (err instanceof AppCrashedError) {
171
+ console.error("app crashed:", err.activeApp);
172
+ }
173
+ }
174
+ ```
175
+
176
+ ### `DeviceUnhealthyError`
177
+
178
+ ```ts
179
+ class DeviceUnhealthyError extends Error {
180
+ readonly name: "DeviceUnhealthyError";
181
+ readonly reason: string;
182
+ }
183
+ ```
184
+
185
+ Thrown by `installDeviceTestHooks`'s `beforeEach` when a previous
186
+ test flipped the health flag. Aborts the remaining tests in the
187
+ worker fast so reviewers aren't wading through 50 cascade failures.
188
+
189
+ ### Fatal device wedge
190
+
191
+ There are two device-health states, exposed as separate helper sets:
192
+
193
+ | Concept | Set | Reset by | Models |
194
+ |---------|-----|----------|--------|
195
+ | Soft health | `markDeviceUnhealthy` / `isDeviceHealthy` / `getUnhealthyReason` | `resetDeviceHealth()` | **Recoverable** fault (crash/reboot the device returns from). |
196
+ | 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. |
197
+
198
+ `markDeviceFatal` also flips the soft flag, so the existing cascade
199
+ keeps firing and you never need to call both. Currently only
200
+ `DeployFailedError({ reason: "never-left-home" })` latches fatal, and
201
+ only on a device with no record of foregrounding a channel. A deploy
202
+ that put one on screen calls `markChannelForegrounded`, which keeps
203
+ later stalled launches on that VM soft — the latch costs the whole
204
+ shard plus a lease recycle, so it is reserved for the case the
205
+ device's own history does not contradict.
206
+
207
+ Consequences once fatal is latched (single `--runInBand` process):
208
+
209
+ - The recovery `beforeEach` in `installDeviceTestHooks` does **not**
210
+ attempt `waitForDeviceOnline` — a wedged VM still answers it, which
211
+ would falsely "recover" and re-run the test. It throws
212
+ `DeviceUnhealthyError` immediately with a "recycle the instance"
213
+ message.
214
+ - `installDeployGuard` (in `device-tests/`) short-circuits: remaining
215
+ suites skip their deploy entirely instead of burning the full
216
+ retry × backoff budget on a dead instance. The device host
217
+ must be recycled at the lease/runner layer.
218
+
219
+ ---
220
+
221
+ ## DOM assertions
222
+
223
+ ### `NodeNotFoundError`
224
+
225
+ ```ts
226
+ class NodeNotFoundError extends Error {
227
+ readonly name: "NodeNotFoundError";
228
+ readonly predicate: NodePredicate;
229
+ readonly treeSummary: string; // truncated pseudo-XML listing
230
+ }
231
+ ```
232
+
233
+ Thrown by `findNode` / `findFocusedNode` / `expectFocused`. The tree
234
+ summary is truncated to the first 3 levels / 2 000 chars so the
235
+ reporter stays scannable.
236
+
237
+ ### `FocusedNodeMismatchError`
238
+
239
+ ```ts
240
+ class FocusedNodeMismatchError extends Error {
241
+ readonly name: "FocusedNodeMismatchError";
242
+ readonly expectedPredicate: NodePredicate;
243
+ readonly actualFocusedNode: string; // one-line summary of the focused node
244
+ }
245
+ ```
246
+
247
+ Thrown by `expectFocused` when a node IS focused but doesn't match
248
+ the predicate.
249
+
250
+ ### `WaitForNodeTimeoutError`
251
+
252
+ ```ts
253
+ class WaitForNodeTimeoutError extends Error {
254
+ readonly name: "WaitForNodeTimeoutError";
255
+ readonly context: {
256
+ predicate: NodePredicate;
257
+ timeoutMs: number;
258
+ lastTree: AppUiNode | null;
259
+ };
260
+ }
261
+ ```
262
+
263
+ Thrown by `client.waitForNode` on timeout. `context.lastTree` is the
264
+ final snapshot the polling loop observed.
265
+
266
+ ### `BaselineMissingError`
267
+
268
+ ```ts
269
+ class BaselineMissingError extends Error {
270
+ readonly name: "BaselineMissingError";
271
+ readonly path: string; // absolute baseline path
272
+ readonly resolution: string; // "hd" | "fhd"
273
+ readonly videoMode: string | undefined;
274
+ }
275
+ ```
276
+
277
+ Thrown by `expectTree(…).toMatchBaseline(path)` when the file does
278
+ not exist and `UPDATE_BASELINES=1` was not set.
279
+
280
+ ### `BaselineResolutionError`
281
+
282
+ ```ts
283
+ class BaselineResolutionError extends Error {
284
+ readonly name: "BaselineResolutionError";
285
+ readonly context: {
286
+ videoMode?: string;
287
+ reason: "unsupported-videoMode" | "no-active-client-and-no-env" | "invalid-env-value";
288
+ };
289
+ }
290
+ ```
291
+
292
+ Thrown when `hd` / `fhd` subdirectory cannot be determined. See
293
+ [baselines.md](baselines.md#resolution-routing) for the precedence
294
+ table.
295
+
296
+ ### `TreeMismatchError`
297
+
298
+ ```ts
299
+ class TreeMismatchError extends Error {
300
+ readonly name: "TreeMismatchError";
301
+ readonly context: {
302
+ baselinePath: string;
303
+ firstDiff: string; // JSONPath (e.g. "$.children[0].attrs.opacity")
304
+ expectedSnippet: string; // truncated baseline snippet
305
+ actualSnippet: string; // truncated actual snippet
306
+ };
307
+ }
308
+ ```
309
+
310
+ Thrown by `expectTree(…).toMatchBaseline(…)` when the tree diverges
311
+ from the baseline.
312
+
313
+ ---
314
+
315
+ ## Screenshot assertions
316
+
317
+ ### `ScreenshotNotStableError`
318
+
319
+ ```ts
320
+ class ScreenshotNotStableError extends Error {
321
+ readonly name: "ScreenshotNotStableError";
322
+ readonly timeoutMs: number;
323
+ readonly iterations: number;
324
+ readonly lastDiffCount: number;
325
+ readonly maskedRegions: MaskRegion[];
326
+ readonly firstDiffPoint?: { x: number; y: number };
327
+ readonly lastPng?: Buffer;
328
+
329
+ toJSON(): Record<string, unknown>; // omits lastPng
330
+ }
331
+ ```
332
+
333
+ Thrown by `captureStableScreenshot` on timeout. `lastPng` is the most
334
+ recent capture; `toJSON` explicitly omits it so
335
+ `JSON.stringify(err)` doesn't balloon the CI log.
336
+
337
+ ### `ScreenshotBaselineMissingError`
338
+
339
+ ```ts
340
+ class ScreenshotBaselineMissingError extends Error {
341
+ readonly name: "ScreenshotBaselineMissingError";
342
+ readonly baselinePath: string;
343
+ readonly resolution: "hd" | "fhd";
344
+ }
345
+ ```
346
+
347
+ Thrown by `expectScreenshot(…).toMatchBaseline(…)` when the baseline
348
+ PNG does not exist and `UPDATE_BASELINES=1` was not set.
349
+
350
+ ### `ScreenshotDimensionsMismatchError`
351
+
352
+ ```ts
353
+ class ScreenshotDimensionsMismatchError extends Error {
354
+ readonly name: "ScreenshotDimensionsMismatchError";
355
+ readonly context: {
356
+ baselinePath: string;
357
+ resolution: "hd" | "fhd";
358
+ baseline: { w: number; h: number };
359
+ actual: { w: number; h: number };
360
+ };
361
+ }
362
+ ```
363
+
364
+ Thrown when baseline and actual PNGs have different sizes. No diff
365
+ PNG is written in this path.
366
+
367
+ ### `ScreenshotMismatchError`
368
+
369
+ ```ts
370
+ class ScreenshotMismatchError extends Error {
371
+ readonly name: "ScreenshotMismatchError";
372
+ readonly context: {
373
+ baselinePath: string;
374
+ resolution: "hd" | "fhd";
375
+ actual: { w: number; h: number };
376
+ mismatchedPixels: number;
377
+ totalPixels: number;
378
+ ratio: number;
379
+ threshold: number;
380
+ diffPath: string;
381
+ alignment?: { dx: number; dy: number };
382
+ };
383
+ }
384
+ ```
385
+
386
+ Thrown when the pixel-mismatch ratio exceeds the threshold. The diff
387
+ PNG is **always** written to `diffPath` before the error is thrown
388
+ — reporters that swallow the error can still attach it.
389
+
390
+ `alignment` appears only when an `alignTolerance` search found a
391
+ translation that scored better than the untranslated comparison, and
392
+ names the offset the reported `ratio` was measured at. A non-zero
393
+ `alignment` on a failure means the surface both moved and changed.
394
+
395
+ ### `ScreenshotMaskError`
396
+
397
+ ```ts
398
+ class ScreenshotMaskError extends Error {
399
+ readonly name: "ScreenshotMaskError";
400
+ readonly context: {
401
+ region: MaskRegion;
402
+ canvas: { w: number; h: number };
403
+ reason: "out-of-bounds" | "zero-area";
404
+ };
405
+ }
406
+ ```
407
+
408
+ Thrown for malformed `MaskRegion` values. Partial overlap is NOT an
409
+ error — those regions are clamped silently.
410
+
411
+ ### `ScreenshotAlignToleranceError`
412
+
413
+ ```ts
414
+ class ScreenshotAlignToleranceError extends Error {
415
+ readonly name: "ScreenshotAlignToleranceError";
416
+ readonly context: {
417
+ axis: "x" | "y";
418
+ value: number;
419
+ max: number;
420
+ reason: "not-an-integer" | "negative" | "over-cap";
421
+ };
422
+ }
423
+ ```
424
+
425
+ Thrown for a malformed `alignTolerance` axis. Unlike masks, nothing is
426
+ clamped — a bad value is a caller bug, and it is validated before the
427
+ comparison so it surfaces on passing runs too.
428
+
429
+ ---
430
+
431
+ ## Log assertions
432
+
433
+ ### `WaitForLogTimeoutError`
434
+
435
+ ```ts
436
+ class WaitForLogTimeoutError extends Error {
437
+ readonly name: "WaitForLogTimeoutError";
438
+ readonly pattern: string;
439
+ readonly since: LogPosition;
440
+ readonly timeoutMs: number;
441
+ readonly linesChecked: number;
442
+ readonly recentLines: LogLine[]; // last 20 entries, capped
443
+ }
444
+ ```
445
+
446
+ Thrown by `waitForLog` on timeout. `linesChecked === 0` means no
447
+ output at all reached the window, re-attach included. That narrows the
448
+ cause but does not settle it: an app with nothing to log in those few
449
+ seconds and a stream stalled at its far end look the same from here,
450
+ and a short window around a quiet moment is the ordinary case. What it
451
+ does rule out is a line lost between the server and this process,
452
+ which the re-attach would have replayed. The message names both
453
+ readings.
454
+
455
+ ### `UnexpectedLogError`
456
+
457
+ ```ts
458
+ class UnexpectedLogError extends Error {
459
+ readonly name: "UnexpectedLogError";
460
+ readonly pattern: string;
461
+ readonly matchedLine: LogLine;
462
+ readonly since: LogPosition;
463
+ }
464
+ ```
465
+
466
+ Thrown by `expectNoLog` when the forbidden pattern matched a line in
467
+ the window.
468
+
469
+ ### `LogStreamGapError`
470
+
471
+ ```ts
472
+ class LogStreamGapError extends Error {
473
+ readonly name: "LogStreamGapError";
474
+ readonly gapStart: LogPosition;
475
+ readonly gapEnd: LogPosition;
476
+ }
477
+ ```
478
+
479
+ Thrown by `waitForLog` / `expectNoLog` / `logs` when an SSE
480
+ reconnect gap overlaps the query range — the missing lines *might*
481
+ have matched, so we fail rather than silently pass.
482
+
483
+ ### `LogBufferOverflowError`
484
+
485
+ ```ts
486
+ class LogBufferOverflowError extends Error {
487
+ readonly name: "LogBufferOverflowError";
488
+ readonly requested: LogPosition;
489
+ readonly minAvailable: LogPosition;
490
+ }
491
+ ```
492
+
493
+ Thrown when a `since` position references lines the buffer has
494
+ already evicted. Fix: raise `LogBuffer` cap, take fresh
495
+ `logPosition()` more often, or shorten assertion windows.
496
+
497
+ ### `LogSessionStartError`
498
+
499
+ ```ts
500
+ class LogSessionStartError extends Error {
501
+ readonly name: "LogSessionStartError";
502
+ readonly serverUrl: string;
503
+ readonly statusCode?: number;
504
+ readonly detail?: string;
505
+ }
506
+ ```
507
+
508
+ Thrown when `POST /logs/start` fails with an unrecoverable HTTP error
509
+ or SSE attach fails before ready. A 400 "already active" response is
510
+ NOT a failure — the session attaches to the existing server-side
511
+ session.
512
+
513
+ ---
514
+
515
+ ## Preconditions
516
+
517
+ ### `PreconditionNotMetError`
518
+
519
+ ```ts
520
+ class PreconditionNotMetError extends Error {
521
+ readonly name: "PreconditionNotMetError";
522
+ readonly field: string;
523
+ readonly expected: string;
524
+ readonly actual: string;
525
+ readonly reason: "mismatch" | "missing-device-info" | "not-implemented";
526
+ }
527
+ ```
528
+
529
+ Thrown by `requirePrecondition`. Registration-time wrappers
530
+ (`itOnDevice` / `describeOnDevice`) catch this class and convert it
531
+ to `it.skip()` / `describe.skip()`.
532
+
533
+ ### `PreconditionNotImplementedError`
534
+
535
+ ```ts
536
+ class PreconditionNotImplementedError extends PreconditionNotMetError {
537
+ readonly name: "PreconditionNotImplementedError";
538
+ readonly feature: string; // e.g. "library-version"
539
+ }
540
+ ```
541
+
542
+ Extends `PreconditionNotMetError` so the skip-converter handles it
543
+ identically. Thrown when the caller asks for a field the SDK cannot
544
+ 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
+