@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
@@ -0,0 +1,490 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import { setActiveClient, setBaselineRoot } from "../assertions/baselines";
4
+ import { setScreenshotDiffRoot } from "../assertions/screenshot-artifacts";
5
+ import { RkClient } from "../client/client";
6
+ import {
7
+ getFatalReason,
8
+ getUnhealthyReason,
9
+ isDeviceFatal,
10
+ isDeviceHealthy,
11
+ resetDeviceHealth
12
+ } from "../client/health";
13
+ import {
14
+ DEVICE_ENV_GLOBAL_KEY,
15
+ DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
16
+ DEVICE_ONLINE_POLL_INTERVAL_MS,
17
+ DEVICE_RECOVERY_DEFAULT_RETRIES
18
+ } from "../constants";
19
+ import { setDeviceEnvFile } from "../device/device-env-sync";
20
+ import { DeviceUnhealthyError } from "../errors";
21
+ import type { DeviceLogSink } from "../logs/device-log-sink";
22
+ import { attachToBuffer, openDeviceLogSink } from "../logs/device-log-sink";
23
+
24
+ /** Options for `installDeviceTestHooks`. */
25
+ export interface InstallDeviceTestHooksOptions {
26
+ /**
27
+ * `RkClient` for log-session auto-start. Omitting skips auto-start
28
+ * with a console warning. Ignored when `startLogSession: false`.
29
+ */
30
+ client?: RkClient;
31
+ /** Opt-out for `beforeAll` log-session attach. Defaults to `true`. */
32
+ startLogSession?: boolean;
33
+ /**
34
+ * Diff PNG output root. Defaults to
35
+ * `<cwd>/test-results/diffs` via the module-level setter.
36
+ */
37
+ diffRoot?: string;
38
+ /**
39
+ * Baselines directory. Defaults to `DEVICE_TEST_BASELINES` env or
40
+ * `<cwd>/baselines`.
41
+ */
42
+ baselineRoot?: string;
43
+ /**
44
+ * `device-env.json` path. Defaults to
45
+ * `<cwd>/test-results/device-env.json`. Registered at install time
46
+ * so the sync `readCachedDeviceInfoSync` helper used by
47
+ * registration-time precondition wrappers resolves consistently.
48
+ */
49
+ deviceEnvFile?: string;
50
+ /**
51
+ * `test-results/` directory. Enables the artifact streams
52
+ * (`device.log` sink + `failures/` bundles) and is exposed to
53
+ * `DeviceTestEnvironment` via `DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR`.
54
+ */
55
+ testResultsDir?: string;
56
+ /**
57
+ * Opt-out for per-failure bundle capture. The `device.log` sink
58
+ * stays active regardless; this only gates the failure bundle pipeline.
59
+ */
60
+ captureFailureBundles?: boolean;
61
+ /**
62
+ * Crash-recovery policy. When enabled, the next `beforeEach` after
63
+ * a failure blocks on `waitForDeviceOnline` and clears the health
64
+ * flag on success (paired with `jest.retryTimes`). Requires
65
+ * `client`; silently demoted to disabled otherwise.
66
+ */
67
+ deviceRecovery?: DeviceRecoveryOptions | false;
68
+ }
69
+
70
+ export interface DeviceRecoveryOptions {
71
+ /** Master switch; defaults to `true` when `client` is provided. */
72
+ enabled?: boolean;
73
+ /** Per-test retry budget; 0 disables retries. */
74
+ retryTimes?: number;
75
+ /** Recovery `waitForDeviceOnline` budget. */
76
+ waitForOnlineTimeoutMs?: number;
77
+ /** Recovery probe interval. */
78
+ pollIntervalMs?: number;
79
+ }
80
+
81
+ /**
82
+ * Tag a hook callback with a stable name and register via `beforeAll`.
83
+ * Tests look up the captured callback by `Function.prototype.name` so a
84
+ * future hook addition cannot silently break index-pinned assertions.
85
+ */
86
+ function namedBeforeAll(name: string, fn: () => unknown): void {
87
+ Object.defineProperty(fn, "name", { value: name, configurable: true });
88
+ beforeAll(fn);
89
+ }
90
+
91
+ /** Symmetric counterpart to `namedBeforeAll` for `afterAll` hooks. */
92
+ function namedAfterAll(name: string, fn: () => unknown): void {
93
+ Object.defineProperty(fn, "name", { value: name, configurable: true });
94
+ afterAll(fn);
95
+ }
96
+
97
+ /** Stable hook-name registry for test-side `findBeforeAllByName` lookups. */
98
+ export const HOOK_NAMES = {
99
+ DIFF_ROOT: "deviceTestHooks:diffRoot",
100
+ DEVICE_INFO_CACHE: "deviceTestHooks:deviceInfoCache",
101
+ LOG_SESSION_ATTACH: "deviceTestHooks:logSessionAttach",
102
+ LOG_SESSION_DETACH: "deviceTestHooks:logSessionDetach",
103
+ DRIVER_RELEASE: "deviceTestHooks:driverRelease"
104
+ } as const;
105
+
106
+ /**
107
+ * Module-level latch that flips on the first successful
108
+ * `installDeviceTestHooks` call. A second call WITHIN THE SAME
109
+ * MODULE INSTANCE would double-register `beforeAll` / `beforeEach` /
110
+ * `afterAll` hooks AND re-issue `jest.retryTimes`, doubling the
111
+ * per-test retry budget. The first symptom is mysterious "this
112
+ * test ran four times" reports; the second is doubled log
113
+ * subscribes leaking memory.
114
+ *
115
+ * IMPORTANT — scope is per-test-file, NOT per-Node-process:
116
+ *
117
+ * Jest creates a fresh module registry for every test file, even
118
+ * under `--runInBand`. `setupFilesAfterEnv` re-evaluates the setup
119
+ * script for each file, which re-imports this module → `HAS_INSTALLED`
120
+ * starts as `false` on every file. That is intentional: every test
121
+ * file legitimately needs its own `beforeAll(ensureLogSession)`
122
+ * because Jest's `beforeAll` is file-scoped.
123
+ *
124
+ * What this latch DOES protect against, then, is a duplicate
125
+ * registration WITHIN THE SAME FILE — almost always a misconfigured
126
+ * `setupFilesAfterEnv` array that lists the setup script twice, or
127
+ * a downstream consumer that imports + calls `installDeviceTestHooks`
128
+ * in addition to the standard setup file.
129
+ *
130
+ * Intentional design choices:
131
+ * - Warn-and-no-op (not throw): identical observable behaviour
132
+ * keeps CI green during the first run while the operator
133
+ * investigates the duplicate registration.
134
+ * - `__resetForTests` exists ONLY for the package's own unit
135
+ * tests; production code MUST NOT touch it.
136
+ */
137
+ let HAS_INSTALLED = false;
138
+
139
+ /**
140
+ * Test-only: clear the install latch so a unit test can call
141
+ * `installDeviceTestHooks` again and observe its hook registrations.
142
+ * Underscored to discourage accidental import in production.
143
+ */
144
+ export function __resetInstallDeviceTestHooksForTests(): void {
145
+ HAS_INSTALLED = false;
146
+ }
147
+
148
+ /**
149
+ * Registers per-worker Jest hooks. Must be called from
150
+ * `setupFilesAfterEnv` so Jest's lifecycle globals are in scope.
151
+ *
152
+ * Install-time (synchronous): `setBaselineRoot` / `setDeviceEnvFile` /
153
+ * `setActiveClient` (registration-time wrappers read these from
154
+ * top-level test code, before `beforeAll` fires); `TEST_RESULTS_DIR` /
155
+ * `CAPTURE_ENABLED` globals (read by `DeviceTestEnvironment`); and
156
+ * `jest.retryTimes(...)` when recovery is enabled.
157
+ *
158
+ * Hooks: `beforeAll` for diff-root, log-session attach (subscribes the
159
+ * `device.log` sink BEFORE `ensureLogSession` so no replay line is
160
+ * dropped); `beforeEach` cascade guard (sync without recovery, async
161
+ * `waitForDeviceOnline` with); `afterAll` log-session detach (unsubs
162
+ * sink first, then `disposeLogSession` — does NOT call `/logs/stop`,
163
+ * which `createGlobalTeardown` owns); `afterEach` no-op (per-test
164
+ * failure artifacts are owned by `DeviceTestEnvironment.handleTestEvent`).
165
+ */
166
+ export function installDeviceTestHooks(opts: InstallDeviceTestHooksOptions = {}): void {
167
+ if (HAS_INSTALLED) {
168
+ // Loud no-op: see HAS_INSTALLED comment above for rationale.
169
+ // Set RK_ALLOW_REINSTALL_HOOKS=1 only when intentionally
170
+ // exercising hook registration twice (e.g. test harness).
171
+ if (process.env.RK_ALLOW_REINSTALL_HOOKS !== "1") {
172
+ console.warn(
173
+ "[device-test-client] installDeviceTestHooks() called more than once in the same Node process. " +
174
+ "Ignoring the second call to avoid double-registering Jest hooks (which would double the per-test " +
175
+ "retry budget and leak duplicate log subscribers). Check that `setupFilesAfterEnv` lists this " +
176
+ "package's setup file only once. Set RK_ALLOW_REINSTALL_HOOKS=1 to bypass."
177
+ );
178
+ return;
179
+ }
180
+ }
181
+ HAS_INSTALLED = true;
182
+ const startLogSession = opts.startLogSession !== false;
183
+ const captureFailureBundles = opts.captureFailureBundles !== false;
184
+ const recovery = resolveRecoveryConfig(opts);
185
+
186
+ // Synchronous install-time registration: registration-time wrappers
187
+ // (`itOnDevice` / `describeOnDevice`) read these from top-level test
188
+ // code, which Jest evaluates BEFORE `beforeAll` fires.
189
+ if (opts.baselineRoot !== undefined) {
190
+ setBaselineRoot(opts.baselineRoot);
191
+ }
192
+ if (opts.deviceEnvFile !== undefined) {
193
+ setDeviceEnvFile(opts.deviceEnvFile);
194
+ }
195
+ if (opts.client) {
196
+ setActiveClient(opts.client);
197
+ }
198
+
199
+ // No `beforeAll(resetDeviceHealth)`: the recovery `beforeEach`
200
+ // below has the only correct cross-suite semantics (probe before
201
+ // reset). A `beforeAll` reset would either bypass the probe or
202
+ // mask the operator signal.
203
+
204
+ // Diff-root in its own `beforeAll` so screenshot-only suites get it
205
+ // even without a log-session client.
206
+ if (opts.diffRoot !== undefined) {
207
+ const diffRoot = opts.diffRoot;
208
+ namedBeforeAll(HOOK_NAMES.DIFF_ROOT, () => {
209
+ setScreenshotDiffRoot(diffRoot);
210
+ });
211
+ }
212
+
213
+ if (opts.client) {
214
+ const client = opts.client;
215
+ namedAfterAll(HOOK_NAMES.DRIVER_RELEASE, () => client.releaseDriver());
216
+ namedBeforeAll(HOOK_NAMES.DEVICE_INFO_CACHE, async () => {
217
+ if (!client.cachedDeviceInfo()) {
218
+ await client.queryDeviceInfo();
219
+ }
220
+ });
221
+ }
222
+
223
+ // Expose `testResultsDir` to `DeviceTestEnvironment` via globals;
224
+ // env reads at `teardown`, so install-time write is visible in time.
225
+ if (captureFailureBundles && opts.testResultsDir !== undefined) {
226
+ const testResultsDir = opts.testResultsDir;
227
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR] = testResultsDir;
228
+ }
229
+
230
+ // Set synchronously so the first `test_fn_failure` event already
231
+ // observes the final value.
232
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.CAPTURE_ENABLED] = captureFailureBundles;
233
+
234
+ if (startLogSession && opts.client) {
235
+ const client = opts.client;
236
+ // Closure-scoped so multiple `installDeviceTestHooks` calls
237
+ // with different configs don't share state.
238
+ let sink: DeviceLogSink | null = null;
239
+ let unsubscribe: (() => void) | null = null;
240
+
241
+ namedBeforeAll(HOOK_NAMES.LOG_SESSION_ATTACH, async () => {
242
+ // Order matters: subscribe BEFORE `ensureLogSession()` so the
243
+ // replay batch and any live lines land in `device.log`. The
244
+ // buffer is constructed eagerly so `client.logBuffer()` is
245
+ // safe before `ensureStarted()`.
246
+ if (opts.testResultsDir !== undefined) {
247
+ try {
248
+ sink = openDeviceLogSink(opts.testResultsDir, client.deviceIp);
249
+ unsubscribe = attachToBuffer(sink, client.logBuffer());
250
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.LOG_SINK] = sink;
251
+ } catch (err) {
252
+ // Non-fatal: CI loses `device.log` for this run but
253
+ // the suite still proceeds.
254
+ console.warn(
255
+ `[device-test-client] device.log sink failed to open: ${(err as Error).message}. ` +
256
+ `Run artifacts will be incomplete.`
257
+ );
258
+ }
259
+ }
260
+
261
+ try {
262
+ await client.ensureLogSession();
263
+ } catch (err) {
264
+ // Non-fatal: the first log-assertion call retries and
265
+ // surfaces the same error with full context.
266
+ console.warn(
267
+ `[device-test-client] ensureLogSession() failed in beforeAll: ${(err as Error).message}. ` +
268
+ `Log assertions will retry on first call.`
269
+ );
270
+ }
271
+ });
272
+
273
+ namedAfterAll(HOOK_NAMES.LOG_SESSION_DETACH, async () => {
274
+ // Unsubscribe FIRST so in-flight appends don't write to a
275
+ // stream that's about to flush.
276
+ if (unsubscribe) {
277
+ try {
278
+ unsubscribe();
279
+ } catch {
280
+ // best-effort
281
+ }
282
+ unsubscribe = null;
283
+ }
284
+ if (sink) {
285
+ try {
286
+ await sink.close();
287
+ } catch {
288
+ // best-effort
289
+ }
290
+ sink = null;
291
+ }
292
+ try {
293
+ client.disposeLogSession();
294
+ } catch {
295
+ // best-effort
296
+ }
297
+ });
298
+ }
299
+
300
+ // Synchronous so tests declared in the same `setupFilesAfterEnv`
301
+ // pass observe the budget. `jest` may be undefined under ts-jest
302
+ // unit-tests of this module — guard.
303
+ if (
304
+ recovery.enabled &&
305
+ recovery.retryTimes > 0 &&
306
+ typeof jest !== "undefined" &&
307
+ typeof jest.retryTimes === "function"
308
+ ) {
309
+ // `logErrorsBeforeRetry: true` distinguishes "flaky" from
310
+ // "genuinely broken" by surfacing per-attempt errors.
311
+ jest.retryTimes(recovery.retryTimes, { logErrorsBeforeRetry: true });
312
+ }
313
+
314
+ // Recovery OFF keeps `beforeEach` sync so unit tests that invoke
315
+ // the captured hook directly observe throw-on-sync behaviour.
316
+ if (recovery.enabled && recovery.client) {
317
+ beforeEach(async () => {
318
+ // FATAL latch is checked FIRST and unconditionally — before the
319
+ // soft-health early-return below. Jest gives every test FILE a
320
+ // fresh module registry (even under `--runInBand`), so the
321
+ // module-local `deviceHealthy` resets to `true` at the start of
322
+ // each file; gating on `isDeviceHealthy()` first would
323
+ // early-return on that fresh-file path and never consult the
324
+ // latch. `isDeviceFatal()` rehydrates from the on-disk sentinel
325
+ // (see health.ts), the only state that survives the reset, so a
326
+ // wedge proved by an earlier file aborts this file's tests
327
+ // immediately instead of probing + retrying a VM that can never
328
+ // foreground a channel. A fatally-wedged VM still answers
329
+ // `/query/device-info`, so the liveness probe below would
330
+ // FALSELY "recover" it — recovery is skipped by design and the
331
+ // instance must be recycled at the lease/runner layer.
332
+ if (isDeviceFatal()) {
333
+ const reason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
334
+ throw new DeviceUnhealthyError(
335
+ `Aborting test: device is fatally wedged — ${reason}. ` +
336
+ `It answers ECP/installer liveness probes but will not foreground a ` +
337
+ `sideloaded channel, so device recovery is skipped (it cannot help). ` +
338
+ `The device host must be recycled. Remaining tests in this worker ` +
339
+ `abort-fast. Per-failure diagnostics are written to ` +
340
+ `test-results/failures/<sanitised-test-path>/ — the FIRST failure in the ` +
341
+ `bundle carries the root cause.`,
342
+ reason
343
+ );
344
+ }
345
+ if (isDeviceHealthy()) return;
346
+ const reason = getUnhealthyReason() ?? "unknown";
347
+ // If `/query/device-info` answers, clear the flag and let
348
+ // `jest.retryTimes` re-execute the crashing test.
349
+ const waitResult = await tryWaitForDeviceOnline(recovery);
350
+ if (waitResult.ok) {
351
+ console.warn(
352
+ `[device-test-client] Device recovered after "${reason}" ` +
353
+ `(attempts=${waitResult.attempts}, elapsedMs=${waitResult.elapsedMs}). ` +
354
+ `Clearing unhealthy flag and continuing.`
355
+ );
356
+ resetDeviceHealth();
357
+ return;
358
+ }
359
+ // Fall through to the cascade error, but enrich it so the
360
+ // reviewer sees WHY recovery didn't help.
361
+ throw new DeviceUnhealthyError(
362
+ `Aborting test: device marked unhealthy — ${reason}. ` +
363
+ `Recovery attempted via waitForDeviceOnline and failed ` +
364
+ `after ${waitResult.elapsedMs}ms (${waitResult.attempts} probes): ` +
365
+ `${waitResult.errorMessage}. Remaining tests in this worker will ` +
366
+ `abort-fast. Per-failure diagnostics are written to ` +
367
+ `test-results/failures/<sanitised-test-path>/ — the ` +
368
+ `FIRST failure in the bundle carries the root cause.`,
369
+ reason
370
+ );
371
+ });
372
+ } else {
373
+ beforeEach(() => {
374
+ // Cross-file FATAL latch first (see the recovery branch above
375
+ // for why order matters): `isDeviceFatal()` rehydrates from the
376
+ // on-disk sentinel, so a wedge proved by an earlier test file
377
+ // aborts this fresh file even though its module-local
378
+ // `deviceHealthy` reset to `true`. The dedicated message names
379
+ // the wedge so triage doesn't mistake it for an ordinary
380
+ // cascade.
381
+ if (isDeviceFatal()) {
382
+ const fatalReason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
383
+ throw new DeviceUnhealthyError(
384
+ `Aborting test: device is fatally wedged — ${fatalReason}. ` +
385
+ `It answers ECP/installer liveness probes but will not foreground a ` +
386
+ `sideloaded channel; retrying cannot recover it. The device host ` +
387
+ `must be recycled. Remaining tests in this worker abort-fast. Per-failure ` +
388
+ `diagnostics are written to test-results/failures/<sanitised-test-path>/ ` +
389
+ `— the FIRST failure in the bundle carries the root cause.`,
390
+ fatalReason
391
+ );
392
+ }
393
+ if (isDeviceHealthy()) return;
394
+ // The cascade error's stack points HERE, not at the test
395
+ // that broke the device — surface the per-failure bundle
396
+ // path inline so reviewers find the root-cause artifacts.
397
+ const reason = getUnhealthyReason() ?? "unknown";
398
+ throw new DeviceUnhealthyError(
399
+ `Aborting test: device marked unhealthy — ${reason}. ` +
400
+ `Remaining tests in this worker will also abort-fast to ` +
401
+ `prevent cascading failures. Per-failure diagnostics ` +
402
+ `(screenshot, dom-tree, recent logs, error) are written to ` +
403
+ `test-results/failures/<sanitised-test-path>/ — the ` +
404
+ `FIRST failure in the bundle carries the root cause.`,
405
+ reason
406
+ );
407
+ });
408
+ }
409
+
410
+ afterEach(() => {
411
+ // No-op anchor. Per-test failure artifacts are written by
412
+ // `DeviceTestEnvironment.handleTestEvent` since `afterEach`
413
+ // cannot observe failure state without a side-channel flag.
414
+ });
415
+ }
416
+
417
+ /** Internal: fully-resolved recovery config. */
418
+ interface ResolvedRecoveryConfig {
419
+ enabled: boolean;
420
+ retryTimes: number;
421
+ waitForOnlineTimeoutMs: number;
422
+ pollIntervalMs: number;
423
+ client?: RkClient;
424
+ }
425
+
426
+ /**
427
+ * Normalise `deviceRecovery` into a concrete config. Opt-in: defaults
428
+ * OFF (legacy fast-fail cascade). "Enabled but no client" silently
429
+ * demotes to disabled rather than throwing — the client is often
430
+ * wired in later in the same `jest-setup.ts`.
431
+ */
432
+ function resolveRecoveryConfig(opts: InstallDeviceTestHooksOptions): ResolvedRecoveryConfig {
433
+ const client = opts.client;
434
+ const raw = opts.deviceRecovery;
435
+ if (raw === false || raw === undefined) {
436
+ return {
437
+ enabled: false,
438
+ retryTimes: 0,
439
+ waitForOnlineTimeoutMs: DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
440
+ pollIntervalMs: DEVICE_ONLINE_POLL_INTERVAL_MS,
441
+ client
442
+ };
443
+ }
444
+ const enabledRequested = raw.enabled ?? true;
445
+ const enabled = enabledRequested && client !== undefined;
446
+ return {
447
+ enabled,
448
+ retryTimes: raw.retryTimes ?? DEVICE_RECOVERY_DEFAULT_RETRIES,
449
+ waitForOnlineTimeoutMs: raw.waitForOnlineTimeoutMs ?? DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
450
+ pollIntervalMs: raw.pollIntervalMs ?? DEVICE_ONLINE_POLL_INTERVAL_MS,
451
+ client
452
+ };
453
+ }
454
+
455
+ interface RecoveryAttemptResult {
456
+ ok: boolean;
457
+ attempts: number;
458
+ elapsedMs: number;
459
+ errorMessage: string;
460
+ }
461
+
462
+ /**
463
+ * Wrap `waitForDeviceOnline` with a never-throw result shape so a
464
+ * recovery-layer bug cannot evict the original cascade message.
465
+ */
466
+ async function tryWaitForDeviceOnline(cfg: ResolvedRecoveryConfig): Promise<RecoveryAttemptResult> {
467
+ const startedAt = Date.now();
468
+ if (!cfg.client) {
469
+ return { ok: false, attempts: 0, elapsedMs: 0, errorMessage: "no client configured" };
470
+ }
471
+ try {
472
+ await cfg.client.waitForDeviceOnline({
473
+ timeoutMs: cfg.waitForOnlineTimeoutMs,
474
+ intervalMs: cfg.pollIntervalMs
475
+ });
476
+ return { ok: true, attempts: 1, elapsedMs: Date.now() - startedAt, errorMessage: "" };
477
+ } catch (err) {
478
+ const attempts =
479
+ typeof err === "object" && err && "context" in err
480
+ ? ((err as { context?: { attempts?: number } }).context?.attempts ?? 0)
481
+ : 0;
482
+ const message = err instanceof Error ? err.message : String(err);
483
+ return {
484
+ ok: false,
485
+ attempts,
486
+ elapsedMs: Date.now() - startedAt,
487
+ errorMessage: message
488
+ };
489
+ }
490
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Barrel re-exports — `jest-setup.ts` was historically the single
5
+ * module hosting every Jest orchestration symbol shipped by this
6
+ * package. The implementations now live in cohesive leaf modules:
7
+ *
8
+ * - `artifacts.ts` — `cleanArtifacts`, `assertSupportedVideoMode`,
9
+ * `writeDeviceEnvSnapshot`, `logDeviceDiagnostic`.
10
+ * - `lease-provisioning.ts` — `readLeaseProvisioning` (lease-recorded
11
+ * provenance folded into `device-env.json`).
12
+ * - `global-setup.ts` — `createGlobalSetup`, `GlobalSetupOptions`.
13
+ * - `global-teardown.ts` — `createGlobalTeardown`, `CreateGlobalTeardownOptions`.
14
+ * - `install-hooks.ts` — `installDeviceTestHooks`, `InstallDeviceTestHooksOptions`.
15
+ * - `baseline-env-guard.ts` — `assertBaselineUpdatesAllowed`, `isCi`.
16
+ * - `telemetry.ts` — device-lease log line + JUnit `<properties>` writer.
17
+ *
18
+ * This file preserves the original import surface so existing callers
19
+ * (`device-tests/setup.ts`, `__tests__/jest-setup.test.ts`, and any
20
+ * downstream consumer) continue to work unchanged. New code should
21
+ * import directly from the leaf modules above.
22
+ */
23
+
24
+ export { assertBaselineUpdatesAllowed, isCi } from "../assertions/baseline-env-guard";
25
+ export { assertSupportedVideoMode, cleanArtifacts, logDeviceDiagnostic, writeDeviceEnvSnapshot } from "./artifacts";
26
+ export type { DeviceEnvSnapshot } from "./artifacts";
27
+ export { readLeaseProvisioning } from "../device/lease-provisioning";
28
+ export type { LeaseProvisioning } from "../device/lease-provisioning";
29
+ export { createGlobalSetup } from "./global-setup";
30
+ export type { GlobalSetupOptions } from "./global-setup";
31
+ export { createGlobalTeardown } from "./global-teardown";
32
+ export type { CreateGlobalTeardownOptions } from "./global-teardown";
33
+ export { installDeviceTestHooks } from "./install-hooks";
34
+ export type { InstallDeviceTestHooksOptions } from "./install-hooks";
35
+ export {
36
+ buildAcquireEvent,
37
+ buildJUnitTelemetryProperties,
38
+ buildReleaseEvent,
39
+ DEFAULT_PROVIDER_KIND,
40
+ DEFAULT_SHARD_NAME,
41
+ DEVICE_LEASE_LOG_PREFIX,
42
+ emitDeviceLeaseLog,
43
+ formatDeviceLeaseLog,
44
+ resolveTelemetry,
45
+ writeJUnitTelemetryPropertiesFile
46
+ } from "./telemetry";
47
+ export type {
48
+ DeviceLeaseAcquireEvent,
49
+ DeviceLeaseEvent,
50
+ DeviceLeaseReleaseEvent,
51
+ DeviceLeaseTelemetry,
52
+ JUnitTelemetryProperties
53
+ } from "./telemetry";