@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,471 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import type { EcpCategory } from "./types";
4
+
5
+ export const ENV_VAR = {
6
+ /** Set to `"true"` or `"1"` by CI runners. */
7
+ CI: "CI",
8
+ /** Set to `"1"` to overwrite committed baselines on the next run. */
9
+ UPDATE_BASELINES: "UPDATE_BASELINES",
10
+ /** Override the default inter-key delay (ms) for `client.keysequence`. */
11
+ KEY_DELAY: "DEVICE_TEST_KEY_DELAY",
12
+ /**
13
+ * Explicit override for the target Roku device IP. When set, bypasses
14
+ * `.roku/` active-device resolution. Must be a local-network IPv4.
15
+ */
16
+ RK_DEVICE_IP: "RK_DEVICE_IP",
17
+ /**
18
+ * Roku developer-mode password. Set together with `RK_DEVICE_IP`
19
+ * to bypass `.roku/roku-dev-config.json`. A partial pair is a
20
+ * misconfiguration and fails loudly with `DeployFailedError`
21
+ * (silent fallback would mask the partial-config case).
22
+ */
23
+ RK_DEVICE_PASSWORD: "RK_DEVICE_PASSWORD",
24
+ /**
25
+ * Absolute path override for the baselines root directory. Primary
26
+ * mechanism is `setBaselineRoot()`; this env var is the one-off
27
+ * escape hatch for scripts run outside the configured Jest setup.
28
+ */
29
+ DEVICE_TEST_BASELINES: "DEVICE_TEST_BASELINES",
30
+ /**
31
+ * Resolution override (`"hd"` | `"fhd"`) for baseline-directory
32
+ * selection when no active `RkClient` has a cached `DeviceInfo`
33
+ * (e.g. ad-hoc CLI use). Ignored once a client has been registered
34
+ * via `setActiveClient()`.
35
+ */
36
+ DEVICE_TEST_RESOLUTION: "DEVICE_TEST_RESOLUTION",
37
+ /**
38
+ * Base URL of the `rk server` daemon. Canonical shape is
39
+ * `http://host:port/api`; bare `http://host:port` is also accepted
40
+ * (normalizer appends `/api`). Defaults to `DEFAULT_RK_SERVER_URL`.
41
+ */
42
+ RK_SERVER_URL: "RK_SERVER_URL",
43
+ /**
44
+ * Per-call idle timeout (ms) for the `POST /api/build` SSE stream.
45
+ * Caps sidecar wedges at a recoverable per-call failure instead
46
+ * of eating the whole CI job. Override when cold esbuild init on
47
+ * constrained CI runners exceeds the default
48
+ * `BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS`. Invalid values warn and
49
+ * fall back to the default — startup must never crash on a typo.
50
+ */
51
+ BUILD_SSE_IDLE_TIMEOUT_MS: "RK_BUILD_SSE_IDLE_TIMEOUT_MS",
52
+ /**
53
+ * Identifier for the GitLab CI shard / job slice running this
54
+ * suite (e.g. "runtime", "components"). Surfaced in JUnit
55
+ * `<properties>` and the `[device-lease]` log so post-hoc
56
+ * telemetry can correlate per-shard wall time and device usage.
57
+ *
58
+ * Defaults to `"default"` when unset so JUnit consumers and
59
+ * dashboards never render an empty value (an empty value is
60
+ * indistinguishable from a missing property in most viewers).
61
+ *
62
+ * Forms part of the parallel-device telemetry baseline and is
63
+ * consumed verbatim by the parallel-shard JUnit naming. Defining
64
+ * it here lets the env-allowlist reference the same name without
65
+ * churn.
66
+ */
67
+ RK_SHARD_NAME: "RK_SHARD_NAME",
68
+ /**
69
+ * Identifier for the device-pool provider that supplied the
70
+ * device for this run (e.g. `"local"`, `"cloud"`).
71
+ * Surfaced in JUnit `<properties>` and the `[device-lease]`
72
+ * log so telemetry can attribute regressions to a specific
73
+ * provider tier.
74
+ *
75
+ * **Forward-compat note.** The original spec called for a
76
+ * hardcoded `"local"` literal at this stage. We plumb an env
77
+ * var with a `"local"` default instead so:
78
+ * - Baseline runs emit the same value (no behavioral
79
+ * diff against the spec).
80
+ * - Provider-backed pools can set this
81
+ * env var from their CLI without retrofitting
82
+ * `device-test-client`.
83
+ *
84
+ * If the provider seam ends up choosing a different injection
85
+ * mechanism (e.g. `ResolvedConfig.pool` via a runtime setter),
86
+ * this env var becomes dead and should be removed.
87
+ */
88
+ RK_PROVIDER_KIND: "RK_PROVIDER_KIND",
89
+ /**
90
+ * ULID of the device-pool lease the current test process should
91
+ * read its target IP + passwordRef from. When set, the resolver's
92
+ * **Layer 0** reads `.roku/leases/<id>.json`, Zod-validates it
93
+ * through `DeviceLeaseSchema`, and returns the embedded `ip` /
94
+ * resolved `passwordRef`.
95
+ *
96
+ * Precedence inside the resolver (later overrides earlier):
97
+ * 1. (Layer 0) `RK_DEVICE_LEASE_ID` — set by `rk device lease
98
+ * acquire`; the canonical CI path for provider-backed pools.
99
+ * 2. `RK_DEVICE_IP` — partner-side explicit override; STILL
100
+ * wins on conflict so we don't surprise partner pipelines.
101
+ * 3. `.roku/` active device → first registered device (today's
102
+ * behavior).
103
+ *
104
+ * Forms the producer side of the parallel-CI model. The acquiring
105
+ * provider writes the lease file; this env var pins
106
+ * the consumer at boot to the correct lease without reading
107
+ * any workspace-singleton state (`.roku/roku-dev-config.json`
108
+ * cannot be safely shared across parallel jobs).
109
+ */
110
+ RK_DEVICE_LEASE_ID: "RK_DEVICE_LEASE_ID",
111
+
112
+ /**
113
+ * Directory holding lease files, defaulting to `<cwd>/.roku/leases`.
114
+ * Set by CI (and `scripts/run-shards-on-devices.sh`) so a job running
115
+ * from a subdirectory still finds the lease the acquire step wrote.
116
+ *
117
+ * Every reader must resolve it identically — two readers disagreeing on
118
+ * the directory silently read two different leases.
119
+ */
120
+ RK_DEVICE_LEASE_DIR: "RK_DEVICE_LEASE_DIR"
121
+ } as const;
122
+
123
+ /** Default delay between successive ECP keypresses, in milliseconds. */
124
+ export const DEFAULT_KEY_DELAY = 100;
125
+
126
+ /**
127
+ * Default per-request ECP timeout for non-capture calls. 10s (not 5s)
128
+ * because `/launch/dev` against an Instant-Resume manifest can take
129
+ * several seconds on slow hardware before returning 200.
130
+ */
131
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
132
+
133
+ /**
134
+ * Retry backoff after a transient ECP failure (5xx or timeout). Callers
135
+ * see a single retry; if the second attempt fails the original error
136
+ * propagates.
137
+ */
138
+ export const ECP_RETRY_BACKOFF_MS = 500;
139
+
140
+ /**
141
+ * Per-category minimum gap (ms) between the END of the previous call
142
+ * and the START of the next. `app-ui` 200ms (waitForNode cadence),
143
+ * `capture` 500ms (expensive); other categories 0 (still serialized).
144
+ */
145
+ export const ECP_INTERVAL_MS: Readonly<Record<EcpCategory, number>> = {
146
+ "query-appui": 200,
147
+ "query-device-info": 0,
148
+ "query-active-app": 0,
149
+ keypress: 0,
150
+ launch: 0,
151
+ capture: 500
152
+ };
153
+
154
+ /** Video modes the framework recognises. Anything else fails fast at startup. */
155
+ export const SUPPORTED_VIDEO_MODES: ReadonlySet<string> = new Set(["720p", "720p60", "1080p", "1080p60"]);
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Device recovery (crash / reboot resilience)
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Default budget for `waitForDeviceOnline`. Roku crash-recovery
163
+ * (reboot + home-screen mount + ECP responder) lands in 60–120s on
164
+ * reference hardware; 180s covers cold-boot on Roku Express without
165
+ * letting a permanent outage hang silently.
166
+ */
167
+ export const DEVICE_ONLINE_DEFAULT_TIMEOUT_MS = 180_000;
168
+
169
+ /**
170
+ * Probe interval inside `waitForDeviceOnline`. Sub-second cadence has
171
+ * been observed to starve `/logs/*` transitions on the rk-server side
172
+ * (single-threaded ECP responder during the crash window).
173
+ */
174
+ export const DEVICE_ONLINE_POLL_INTERVAL_MS = 2_000;
175
+
176
+ /**
177
+ * Default budget for the installer-port (HTTP/80) phase of
178
+ * `waitForDeviceFullyReady`. The installer is what `rk` POSTs the
179
+ * sideload package to, and on reference hardware it lags ECP recovery
180
+ * by a few seconds after a crash/reboot — long enough that probing only
181
+ * `/query/device-info` (port 8060) and immediately retrying a deploy
182
+ * yields the misleading "Device is not reachable on the network"
183
+ * diagnostic. 60s covers the observed lag with headroom while still
184
+ * keeping the overall guard budget bounded.
185
+ */
186
+ export const INSTALLER_PORT_PROBE_DEFAULT_TIMEOUT_MS = 60_000;
187
+
188
+ /**
189
+ * Probe interval for the installer-port arm of
190
+ * `waitForDeviceFullyReady`. 1s is fast enough to catch the typical
191
+ * 2–10s post-ECP installer recovery window without flooding the device
192
+ * with TCP SYNs while it is still booting.
193
+ */
194
+ export const INSTALLER_PORT_PROBE_INTERVAL_MS = 1_000;
195
+
196
+ /**
197
+ * Per-attempt TCP-connect timeout for the installer-port probe. Mirrors
198
+ * `tcpProbe`'s default in `@roku-sdk/tooling`; surfaced here so tests
199
+ * and callers can reason about probe latency without crossing package
200
+ * boundaries.
201
+ */
202
+ export const INSTALLER_PORT_TCP_TIMEOUT_MS = 1_500;
203
+
204
+ /**
205
+ * Test-level retries for device recovery. Higher counts mask real
206
+ * regressions; lower counts leave first-observed crashes unrecovered.
207
+ */
208
+ export const DEVICE_RECOVERY_DEFAULT_RETRIES = 3;
209
+
210
+ /**
211
+ * Default post-sideload readiness deadline. Covers cold-start on the
212
+ * slowest reference hardware (Express ~3s, Ultra ~1s) with headroom
213
+ * for GC/HDCP stalls. Pass `readinessTimeout` explicitly for slower fixtures.
214
+ */
215
+ export const READINESS_TIMEOUT_MS = 10_000;
216
+
217
+ /**
218
+ * Sleep between readiness probes on the `/query/active-app` arm
219
+ * (which has a 0ms queue gap). Avoids DoS-ing the device during
220
+ * sideload transitions.
221
+ */
222
+ export const READINESS_POLL_INTERVAL_MS = 200;
223
+
224
+ /**
225
+ * Consecutive predicate passes before declaring ready. Two catches
226
+ * crashes shortly after first render (observed `EXIT_APP_ERROR` ~300ms
227
+ * after splash); higher counts add deploy latency for diminishing
228
+ * returns since later `assertAppAlive` calls cover longer windows.
229
+ */
230
+ export const READINESS_CONFIRM_POLLS = 2;
231
+
232
+ /**
233
+ * Fraction of the readiness budget tolerating post-launch home-screen
234
+ * responses before a previously-observed app is treated as
235
+ * `crashed-after-ready`. `/launch/dev` returns 200 before firmware
236
+ * swaps the active app (~200–500ms transition), and InstantOn can
237
+ * briefly bounce through Home after first render. A never-observed app
238
+ * is classified only at the full readiness timeout; slow cold sideloads
239
+ * can remain on Home longer than this grace window and still launch.
240
+ */
241
+ export const HOME_SCREEN_GRACE_FRACTION = 0.25;
242
+
243
+ /**
244
+ * Absolute floor for the home-screen grace window. Without it, a
245
+ * sub-second readiness budget would compute a grace below the
246
+ * `/launch/dev` → off-home transition and false-positive.
247
+ */
248
+ export const HOME_SCREEN_GRACE_MIN_MS = 500;
249
+
250
+ /**
251
+ * Tag names of root-level system dialogs (e.g. "Display type detection").
252
+ * Matching on tag at the root rules out app-authored `Dialog` subclasses
253
+ * (those live inside a mounted Scene, not as root siblings).
254
+ */
255
+ export const SYSTEM_DIALOG_TAGS: ReadonlySet<string> = new Set([
256
+ "Dialog",
257
+ "StandardMessageDialog",
258
+ "StandardDialog",
259
+ "ProblemDialog",
260
+ "KeyboardDialog",
261
+ "PinEntryDialog"
262
+ ]);
263
+
264
+ /**
265
+ * Default `waitForNode` cadence; matches the `/query/app-ui` queue gap
266
+ * so polling lands naturally on the queue's rhythm.
267
+ */
268
+ export const WAIT_FOR_NODE_DEFAULT_INTERVAL_MS = 200;
269
+
270
+ /** Max serialized depth in `summarizeTree`; deeper subtrees become `[N children]`. */
271
+ export const TREE_SUMMARY_MAX_DEPTH = 3;
272
+
273
+ /** Hard cap on `summarizeTree` output length. */
274
+ export const TREE_SUMMARY_MAX_CHARS = 2000;
275
+
276
+ /** Snippet length for expected/actual JSON values in `TreeMismatchError`. */
277
+ export const TREE_DIFF_SNIPPET_MAX_CHARS = 200;
278
+
279
+ /** Baseline directory name per-resolution. */
280
+ export const BASELINE_RESOLUTION_DIR = {
281
+ hd: "hd",
282
+ fhd: "fhd"
283
+ } as const;
284
+
285
+ /** Filenames written under each consumer's `test-results/` directory. */
286
+ export const ARTIFACT_FILE = {
287
+ DEVICE_ENV: "device-env.json",
288
+ JUNIT: "junit.xml",
289
+ DEVICE_LOG: "device.log",
290
+ /**
291
+ * CommonJS file written by the telemetry helper that the
292
+ * `jest-junit` reporter `require()`s at JUnit generation
293
+ * time to add per-`<testcase>` `<properties>` entries
294
+ * (`device-id`, `shard-name`, `providerKind`). Picked up via
295
+ * `jest-junit`'s `testCasePropertiesFile` /
296
+ * `testCasePropertiesDirectory` options. Filename is exposed
297
+ * as a constant so the consumer's `jest.config.ts` and the
298
+ * writer in `telemetry.ts` reference the same string.
299
+ */
300
+ JUNIT_CASE_PROPERTIES: "junit-test-case-properties.js"
301
+ } as const;
302
+
303
+ /**
304
+ * Optional `.passthrough()` riders on `.roku/leases/<id>.json` that this
305
+ * package reads. Not declared by `DeviceLeaseSchema`, so the names are the
306
+ * whole contract — they must match the provider that stamps them (see the
307
+ * "Known passthrough riders" list in
308
+ * `@roku-sdk/tooling/src/device-pool/schemas/lease.ts`).
309
+ */
310
+ export const LEASE_FIELD = {
311
+ /**
312
+ * OS image the pool provider flashed the device with before handing out
313
+ * the lease. Recorded verbatim in `device-env.json` so a failed run can
314
+ * be traced back to an image: the ECP `firmwareVersion` is too coarse
315
+ * (several images report the same `ui-software` version) and the
316
+ * provider-side libraries (`grandcentral`, `trc-channel`) can differ
317
+ * between two devices that report identical firmware.
318
+ */
319
+ ROOTFS_ARTIFACTORY_PATH: "rootfsArtifactoryPath"
320
+ } as const;
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Failure diagnostics
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /** Directory (relative to `testResultsDir`) for per-failure bundles. */
327
+ export const FAILURES_DIR = "failures";
328
+
329
+ /**
330
+ * Filenames inside each per-failure bundle. Keep in sync with
331
+ * `writeFailureBundle` — CI artifact pipelines key off these names.
332
+ */
333
+ export const FAILURE_ARTIFACT_FILE = {
334
+ SCREENSHOT: "screenshot.png",
335
+ DOM_TREE: "dom-tree.json",
336
+ RECENT_LOGS: "recent-logs.txt",
337
+ ERROR: "error.txt",
338
+ /**
339
+ * `LogStreamHealth` snapshot at failure time; lets a reviewer
340
+ * attribute a log timeout to either the device→server telnet leg
341
+ * or the server→client SSE leg without rerunning.
342
+ */
343
+ STREAM_HEALTH: "stream-health.json"
344
+ } as const;
345
+
346
+ /** Default tail size for `recent-logs.txt`. */
347
+ export const FAILURE_RECENT_LOG_LINES = 100;
348
+
349
+ /**
350
+ * Global keys shared between consumer `setup.ts` and the custom Jest
351
+ * test environment. Globals (not env options) because Jest constructs
352
+ * the environment in a different module context from `setup.ts` and
353
+ * globals are the only surface both sides reach.
354
+ */
355
+ export const DEVICE_ENV_GLOBAL_KEY = {
356
+ CLIENT: "__DEVICE_CLIENT__",
357
+ TEST_RESULTS_DIR: "__DEVICE_TEST_RESULTS_DIR__",
358
+ LOG_SINK: "__DEVICE_LOG_SINK__",
359
+ /**
360
+ * Opt-out switch for the failure-bundle pipeline. Written
361
+ * synchronously at install time (not in `beforeAll`) so the first
362
+ * `test_fn_failure` event already sees the final value. Absent
363
+ * key + absent `TEST_RESULTS_DIR` → env stays silent (vanilla
364
+ * `jest-environment-node` consumers don't get unsolicited bundles).
365
+ */
366
+ CAPTURE_ENABLED: "__DEVICE_CAPTURE_ENABLED__"
367
+ } as const;
368
+
369
+ /**
370
+ * Per-suite cache of the IP resolved by `globalSetup`. Workers read
371
+ * synchronously from `createDefaultClient()` since `listMergedDevices`
372
+ * is async and the worker-side factory is sync.
373
+ */
374
+ export const DEVICE_IP_CACHE_FILE = ".device-ip";
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Log assertions
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Default `rk server` base URL. Canonical form includes `/api` (matches
382
+ * `apiEndpoint` in `.roku/rk-server.json`); bare `http://host:port` is
383
+ * normalized by `resolveServerUrl`.
384
+ */
385
+ export const DEFAULT_RK_SERVER_URL = "http://localhost:3737/api";
386
+
387
+ /** Roku device console log port. Mirror of `tooling-utils/constants.ts → ROKU_LOG_PORT`. */
388
+ export const ROKU_LOG_PORT = 8085;
389
+
390
+ /**
391
+ * Hard cap on retained log entries per worker. ~10k × ~200 B ≈ 2MB
392
+ * keeps a long-running suite within memory bounds; an earlier 200k
393
+ * cap drove the device-test container past its cgroup limit.
394
+ */
395
+ export const LOG_BUFFER_MAX_LINES = 10_000;
396
+
397
+ /**
398
+ * Oldest entries dropped on cap. Half-drop is amortised O(1) per
399
+ * append vs drop-one-per-append; the doubled retention window absorbs
400
+ * short `since`-pos snapshots without surprise overflows.
401
+ */
402
+ export const LOG_BUFFER_DROP_HALF = 5_000;
403
+
404
+ /** Default `waitForLog` timeout. */
405
+ export const WAIT_FOR_LOG_DEFAULT_TIMEOUT_MS = 5_000;
406
+
407
+ /** Upper bound on `recentLines` rendered into `WaitForLogTimeoutError`. */
408
+ export const WAIT_FOR_LOG_RECENT_LINES_MAX = 20;
409
+
410
+ /** Character cap on the recentLines preview block. */
411
+ export const WAIT_FOR_LOG_RECENT_CHARS_MAX = 600;
412
+
413
+ /**
414
+ * SSE parser `pending` accumulator cap. Frames are normally a few
415
+ * hundred bytes; a stream past 1 MiB without `\n\n` is broken or
416
+ * hostile — fail loud rather than leak memory.
417
+ */
418
+ export const SSE_PENDING_MAX_BYTES = 1_048_576;
419
+
420
+ /**
421
+ * Default per-call idle timeout (ms) for the `POST /api/build` SSE
422
+ * stream consumed by `requestServerBuild`. Sized for the worst-case
423
+ * cold path on constrained CI runners: first-time esbuild service
424
+ * spawn (~10–15s on shared CI containers) + Hermes warmup
425
+ * (~30–60s) + first incremental TS resolve (~30s). 90s — the prior
426
+ * value — could trip on cold starts and surface as the opaque
427
+ * `RK_SERVER_BUILD_IDLE_TIMEOUT` code, indistinguishable from a
428
+ * real build error. 180s comfortably covers cold-start without
429
+ * letting a permanent sidecar wedge eat the whole job's budget.
430
+ *
431
+ * Override per-environment via the `RK_BUILD_SSE_IDLE_TIMEOUT_MS`
432
+ * env var (any positive integer, in ms). Invalid values warn and
433
+ * fall back to this default rather than crashing the test run.
434
+ */
435
+ export const BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS = 180_000;
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Screenshot assertions
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * Default convergence interval for `captureStableScreenshot`. Matches
443
+ * the ECP queue's `capture` gap so polling lands on the queue's cadence.
444
+ */
445
+ export const SCREENSHOT_STABLE_DEFAULT_INTERVAL_MS = 500;
446
+
447
+ /**
448
+ * Default pixel-mismatch ratio for `expectScreenshot` (1% of the
449
+ * unmasked canvas). Tolerates anti-aliasing drift without missing
450
+ * legitimate content changes.
451
+ */
452
+ export const DEFAULT_SCREENSHOT_THRESHOLD = 0.01;
453
+
454
+ /**
455
+ * Per-pixel YIQ cutoff for `pixelmatch`. Decoupled from the caller-facing
456
+ * threshold (which is the aggregate-ratio knob); 0.1 is pixelmatch's
457
+ * own library default.
458
+ */
459
+ export const PIXELMATCH_COLOR_THRESHOLD = 0.1;
460
+
461
+ /**
462
+ * Per-axis ceiling for `ScreenshotMatchOptions.alignTolerance`. The
463
+ * search is O((2x+1)(2y+1)) candidate offsets, so the cap bounds both
464
+ * the runtime and how far the comparator may slide a capture before
465
+ * scoring it — a wide window could "align" genuinely different content.
466
+ * Observed device-dependent OSD re-centering is 1-3px.
467
+ */
468
+ export const ALIGN_TOLERANCE_MAX_PX = 4;
469
+
470
+ /** Default diff-PNG directory; overridable via `setScreenshotDiffRoot`. */
471
+ export const SCREENSHOT_DIFF_DIR = "test-results/diffs";
@@ -0,0 +1,57 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `modelName` → coarse performance tier for precondition gating.
5
+ * Separate from `DeviceInfo.deviceClass` (which is a form-factor
6
+ * classifier). Hand-maintained because the model→tier mapping is
7
+ * not algorithmic. Unknown models → `"unknown"` so preconditions
8
+ * fail closed.
9
+ */
10
+
11
+ export type DeviceClass = "high-end" | "mid-range" | "low-end" | "unknown";
12
+
13
+ /**
14
+ * Normalise for table lookup: lowercase, trim, strip `"roku "`
15
+ * prefix so `"Roku Ultra"` and `"Ultra"` resolve identically.
16
+ */
17
+ function normalizeModel(modelName: string): string {
18
+ const lower = modelName.trim().toLowerCase();
19
+ return lower.startsWith("roku ") ? lower.slice("roku ".length) : lower;
20
+ }
21
+
22
+ /**
23
+ * Static `modelName` → tier table. Conservative: prefer `"unknown"`
24
+ * for new models until they're manually classified.
25
+ */
26
+ const MODEL_CLASS_TABLE: ReadonlyMap<string, DeviceClass> = new Map([
27
+ ["express", "low-end"],
28
+ ["express 4k", "mid-range"],
29
+ ["express 4k+", "mid-range"],
30
+ ["streaming stick", "low-end"],
31
+ ["streaming stick+", "mid-range"],
32
+ ["streaming stick 4k", "mid-range"],
33
+ ["streaming stick 4k+", "mid-range"],
34
+ ["premiere", "mid-range"],
35
+ ["premiere+", "mid-range"],
36
+ ["ultra", "high-end"],
37
+ ["ultra lt", "mid-range"],
38
+ // Roku TVs keyed by the strings ECP actually reports.
39
+ ["roku tv", "mid-range"],
40
+ ["4k roku tv", "mid-range"],
41
+ ["roku ultra lt", "mid-range"],
42
+ ["reference rsg-4k", "high-end"],
43
+ ["reference rsg-hd", "low-end"],
44
+ ["streambar", "low-end"],
45
+ ["streambar pro", "mid-range"],
46
+ ["wireless soundbar", "mid-range"]
47
+ ]);
48
+
49
+ /**
50
+ * Map `modelName` to a tier. Pure (no I/O, no state).
51
+ * `"unknown"` for unmapped models so preconditions fail closed.
52
+ */
53
+ export function getDeviceClass(modelName: string): DeviceClass {
54
+ if (!modelName) return "unknown";
55
+ const hit = MODEL_CLASS_TABLE.get(normalizeModel(modelName));
56
+ return hit ?? "unknown";
57
+ }
@@ -0,0 +1,58 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Synchronous `DeviceInfo` accessor backed by
5
+ * `test-results/device-env.json` (written by `globalSetup`).
6
+ *
7
+ * `itOnDevice`/`describeOnDevice` evaluate preconditions at
8
+ * registration time — before any `beforeAll` has fired, so the
9
+ * live `RkClient` has no cached info yet. Reading from disk is the
10
+ * simplest shape that preserves the "one ECP round-trip per suite"
11
+ * contract.
12
+ *
13
+ * Missing file → return null (caller falls back to "register
14
+ * normally", no skip) so a fresh clone boots cleanly.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { ARTIFACT_FILE } from "../constants";
21
+ import { TEST_RESULTS_DIR_NAME } from "../paths";
22
+ import type { DeviceInfo } from "../types";
23
+
24
+ // Path resolved lazily from cwd so a `chdir` before hooks still
25
+ // lands correctly. Consumers override via `setDeviceEnvFile()` to
26
+ // anchor on `__dirname` (robust to worker cwd changes).
27
+ let overridePath: string | null = null;
28
+
29
+ export function setDeviceEnvFile(absPath: string): void {
30
+ overridePath = absPath;
31
+ }
32
+
33
+ export function getDeviceEnvFile(): string {
34
+ return overridePath ?? join(process.cwd(), TEST_RESULTS_DIR_NAME, ARTIFACT_FILE.DEVICE_ENV);
35
+ }
36
+
37
+ /**
38
+ * Read and parse `device-env.json`. Returns null when missing or
39
+ * unparseable so precondition evaluators see "no cache" consistently.
40
+ * Synchronous because `itOnDevice`/`describeOnDevice` can't await.
41
+ */
42
+ export function readCachedDeviceInfoSync(): DeviceInfo | null {
43
+ const file = getDeviceEnvFile();
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ const raw = readFileSync(file, "utf8");
47
+ const parsed = JSON.parse(raw) as DeviceInfo;
48
+ if (!parsed || typeof parsed !== "object") return null;
49
+ return parsed;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Test-only reset for the `setDeviceEnvFile` override. */
56
+ export function resetDeviceEnvFileOverride(): void {
57
+ overridePath = null;
58
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Device IP resolution — sync side (worker-safe).
5
+ *
6
+ * Intentionally has no `@roku-sdk/tooling` imports: the tooling's
7
+ * device-store module is ESM and breaks CommonJS consumers at load
8
+ * time via `import.meta.url`. Keep this file import-light.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { DEVICE_IP_CACHE_FILE, ENV_VAR } from "../constants";
15
+ import { RkClientError } from "../errors";
16
+
17
+ /**
18
+ * Return the device IP to use from:
19
+ * 1. `RK_DEVICE_IP` env var, then
20
+ * 2. `testResultsDir/.device-ip` written by `globalSetup`.
21
+ *
22
+ * Throws `RkClientError(RK_DEVICE_IP_UNRESOLVED)` otherwise.
23
+ */
24
+ export function resolveDeviceIpSync(testResultsDir?: string): string {
25
+ const envIp = process.env[ENV_VAR.RK_DEVICE_IP];
26
+ if (envIp && envIp.length > 0) return envIp;
27
+
28
+ if (testResultsDir) {
29
+ try {
30
+ const cached = readFileSync(join(testResultsDir, DEVICE_IP_CACHE_FILE), "utf8").trim();
31
+ if (cached.length > 0) return cached;
32
+ } catch (err) {
33
+ const code = (err as NodeJS.ErrnoException).code;
34
+ if (code !== "ENOENT") throw err;
35
+ }
36
+ }
37
+
38
+ throw new RkClientError(
39
+ `Unable to resolve a Roku device IP. Set ${ENV_VAR.RK_DEVICE_IP}=<ip> or ensure ` +
40
+ `the suite's global-setup has run and written ${DEVICE_IP_CACHE_FILE}.`,
41
+ "RK_DEVICE_IP_UNRESOLVED"
42
+ );
43
+ }