@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,488 @@
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
+ } as const;
104
+
105
+ /**
106
+ * Module-level latch that flips on the first successful
107
+ * `installDeviceTestHooks` call. A second call WITHIN THE SAME
108
+ * MODULE INSTANCE would double-register `beforeAll` / `beforeEach` /
109
+ * `afterAll` hooks AND re-issue `jest.retryTimes`, doubling the
110
+ * per-test retry budget. The first symptom is mysterious "this
111
+ * test ran four times" reports; the second is doubled log
112
+ * subscribes leaking memory.
113
+ *
114
+ * IMPORTANT — scope is per-test-file, NOT per-Node-process:
115
+ *
116
+ * Jest creates a fresh module registry for every test file, even
117
+ * under `--runInBand`. `setupFilesAfterEnv` re-evaluates the setup
118
+ * script for each file, which re-imports this module → `HAS_INSTALLED`
119
+ * starts as `false` on every file. That is intentional: every test
120
+ * file legitimately needs its own `beforeAll(ensureLogSession)`
121
+ * because Jest's `beforeAll` is file-scoped.
122
+ *
123
+ * What this latch DOES protect against, then, is a duplicate
124
+ * registration WITHIN THE SAME FILE — almost always a misconfigured
125
+ * `setupFilesAfterEnv` array that lists the setup script twice, or
126
+ * a downstream consumer that imports + calls `installDeviceTestHooks`
127
+ * in addition to the standard setup file.
128
+ *
129
+ * Intentional design choices:
130
+ * - Warn-and-no-op (not throw): identical observable behaviour
131
+ * keeps CI green during the first run while the operator
132
+ * investigates the duplicate registration.
133
+ * - `__resetForTests` exists ONLY for the package's own unit
134
+ * tests; production code MUST NOT touch it.
135
+ */
136
+ let HAS_INSTALLED = false;
137
+
138
+ /**
139
+ * Test-only: clear the install latch so a unit test can call
140
+ * `installDeviceTestHooks` again and observe its hook registrations.
141
+ * Underscored to discourage accidental import in production.
142
+ */
143
+ export function __resetInstallDeviceTestHooksForTests(): void {
144
+ HAS_INSTALLED = false;
145
+ }
146
+
147
+ /**
148
+ * Registers per-worker Jest hooks. Must be called from
149
+ * `setupFilesAfterEnv` so Jest's lifecycle globals are in scope.
150
+ *
151
+ * Install-time (synchronous): `setBaselineRoot` / `setDeviceEnvFile` /
152
+ * `setActiveClient` (registration-time wrappers read these from
153
+ * top-level test code, before `beforeAll` fires); `TEST_RESULTS_DIR` /
154
+ * `CAPTURE_ENABLED` globals (read by `DeviceTestEnvironment`); and
155
+ * `jest.retryTimes(...)` when recovery is enabled.
156
+ *
157
+ * Hooks: `beforeAll` for diff-root, log-session attach (subscribes the
158
+ * `device.log` sink BEFORE `ensureLogSession` so no replay line is
159
+ * dropped); `beforeEach` cascade guard (sync without recovery, async
160
+ * `waitForDeviceOnline` with); `afterAll` log-session detach (unsubs
161
+ * sink first, then `disposeLogSession` — does NOT call `/logs/stop`,
162
+ * which `createGlobalTeardown` owns); `afterEach` no-op (per-test
163
+ * failure artifacts are owned by `DeviceTestEnvironment.handleTestEvent`).
164
+ */
165
+ export function installDeviceTestHooks(opts: InstallDeviceTestHooksOptions = {}): void {
166
+ if (HAS_INSTALLED) {
167
+ // Loud no-op: see HAS_INSTALLED comment above for rationale.
168
+ // Set RK_ALLOW_REINSTALL_HOOKS=1 only when intentionally
169
+ // exercising hook registration twice (e.g. test harness).
170
+ if (process.env.RK_ALLOW_REINSTALL_HOOKS !== "1") {
171
+ console.warn(
172
+ "[device-test-client] installDeviceTestHooks() called more than once in the same Node process. " +
173
+ "Ignoring the second call to avoid double-registering Jest hooks (which would double the per-test " +
174
+ "retry budget and leak duplicate log subscribers). Check that `setupFilesAfterEnv` lists this " +
175
+ "package's setup file only once. Set RK_ALLOW_REINSTALL_HOOKS=1 to bypass."
176
+ );
177
+ return;
178
+ }
179
+ }
180
+ HAS_INSTALLED = true;
181
+ const startLogSession = opts.startLogSession !== false;
182
+ const captureFailureBundles = opts.captureFailureBundles !== false;
183
+ const recovery = resolveRecoveryConfig(opts);
184
+
185
+ // Synchronous install-time registration: registration-time wrappers
186
+ // (`itOnDevice` / `describeOnDevice`) read these from top-level test
187
+ // code, which Jest evaluates BEFORE `beforeAll` fires.
188
+ if (opts.baselineRoot !== undefined) {
189
+ setBaselineRoot(opts.baselineRoot);
190
+ }
191
+ if (opts.deviceEnvFile !== undefined) {
192
+ setDeviceEnvFile(opts.deviceEnvFile);
193
+ }
194
+ if (opts.client) {
195
+ setActiveClient(opts.client);
196
+ }
197
+
198
+ // No `beforeAll(resetDeviceHealth)`: the recovery `beforeEach`
199
+ // below has the only correct cross-suite semantics (probe before
200
+ // reset). A `beforeAll` reset would either bypass the probe or
201
+ // mask the operator signal.
202
+
203
+ // Diff-root in its own `beforeAll` so screenshot-only suites get it
204
+ // even without a log-session client.
205
+ if (opts.diffRoot !== undefined) {
206
+ const diffRoot = opts.diffRoot;
207
+ namedBeforeAll(HOOK_NAMES.DIFF_ROOT, () => {
208
+ setScreenshotDiffRoot(diffRoot);
209
+ });
210
+ }
211
+
212
+ if (opts.client) {
213
+ const client = opts.client;
214
+ namedBeforeAll(HOOK_NAMES.DEVICE_INFO_CACHE, async () => {
215
+ if (!client.cachedDeviceInfo()) {
216
+ await client.queryDeviceInfo();
217
+ }
218
+ });
219
+ }
220
+
221
+ // Expose `testResultsDir` to `DeviceTestEnvironment` via globals;
222
+ // env reads at `teardown`, so install-time write is visible in time.
223
+ if (captureFailureBundles && opts.testResultsDir !== undefined) {
224
+ const testResultsDir = opts.testResultsDir;
225
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR] = testResultsDir;
226
+ }
227
+
228
+ // Set synchronously so the first `test_fn_failure` event already
229
+ // observes the final value.
230
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.CAPTURE_ENABLED] = captureFailureBundles;
231
+
232
+ if (startLogSession && opts.client) {
233
+ const client = opts.client;
234
+ // Closure-scoped so multiple `installDeviceTestHooks` calls
235
+ // with different configs don't share state.
236
+ let sink: DeviceLogSink | null = null;
237
+ let unsubscribe: (() => void) | null = null;
238
+
239
+ namedBeforeAll(HOOK_NAMES.LOG_SESSION_ATTACH, async () => {
240
+ // Order matters: subscribe BEFORE `ensureLogSession()` so the
241
+ // replay batch and any live lines land in `device.log`. The
242
+ // buffer is constructed eagerly so `client.logBuffer()` is
243
+ // safe before `ensureStarted()`.
244
+ if (opts.testResultsDir !== undefined) {
245
+ try {
246
+ sink = openDeviceLogSink(opts.testResultsDir, client.deviceIp);
247
+ unsubscribe = attachToBuffer(sink, client.logBuffer());
248
+ (globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.LOG_SINK] = sink;
249
+ } catch (err) {
250
+ // Non-fatal: CI loses `device.log` for this run but
251
+ // the suite still proceeds.
252
+ console.warn(
253
+ `[device-test-client] device.log sink failed to open: ${(err as Error).message}. ` +
254
+ `Run artifacts will be incomplete.`
255
+ );
256
+ }
257
+ }
258
+
259
+ try {
260
+ await client.ensureLogSession();
261
+ } catch (err) {
262
+ // Non-fatal: the first log-assertion call retries and
263
+ // surfaces the same error with full context.
264
+ console.warn(
265
+ `[device-test-client] ensureLogSession() failed in beforeAll: ${(err as Error).message}. ` +
266
+ `Log assertions will retry on first call.`
267
+ );
268
+ }
269
+ });
270
+
271
+ namedAfterAll(HOOK_NAMES.LOG_SESSION_DETACH, async () => {
272
+ // Unsubscribe FIRST so in-flight appends don't write to a
273
+ // stream that's about to flush.
274
+ if (unsubscribe) {
275
+ try {
276
+ unsubscribe();
277
+ } catch {
278
+ // best-effort
279
+ }
280
+ unsubscribe = null;
281
+ }
282
+ if (sink) {
283
+ try {
284
+ await sink.close();
285
+ } catch {
286
+ // best-effort
287
+ }
288
+ sink = null;
289
+ }
290
+ try {
291
+ client.disposeLogSession();
292
+ } catch {
293
+ // best-effort
294
+ }
295
+ });
296
+ }
297
+
298
+ // Synchronous so tests declared in the same `setupFilesAfterEnv`
299
+ // pass observe the budget. `jest` may be undefined under ts-jest
300
+ // unit-tests of this module — guard.
301
+ if (
302
+ recovery.enabled &&
303
+ recovery.retryTimes > 0 &&
304
+ typeof jest !== "undefined" &&
305
+ typeof jest.retryTimes === "function"
306
+ ) {
307
+ // `logErrorsBeforeRetry: true` distinguishes "flaky" from
308
+ // "genuinely broken" by surfacing per-attempt errors.
309
+ jest.retryTimes(recovery.retryTimes, { logErrorsBeforeRetry: true });
310
+ }
311
+
312
+ // Recovery OFF keeps `beforeEach` sync so unit tests that invoke
313
+ // the captured hook directly observe throw-on-sync behaviour.
314
+ if (recovery.enabled && recovery.client) {
315
+ beforeEach(async () => {
316
+ // FATAL latch is checked FIRST and unconditionally — before the
317
+ // soft-health early-return below. Jest gives every test FILE a
318
+ // fresh module registry (even under `--runInBand`), so the
319
+ // module-local `deviceHealthy` resets to `true` at the start of
320
+ // each file; gating on `isDeviceHealthy()` first would
321
+ // early-return on that fresh-file path and never consult the
322
+ // latch. `isDeviceFatal()` rehydrates from the on-disk sentinel
323
+ // (see health.ts), the only state that survives the reset, so a
324
+ // wedge proved by an earlier file aborts this file's tests
325
+ // immediately instead of probing + retrying a VM that can never
326
+ // foreground a channel. A fatally-wedged VM still answers
327
+ // `/query/device-info`, so the liveness probe below would
328
+ // FALSELY "recover" it — recovery is skipped by design and the
329
+ // instance must be recycled at the lease/runner layer.
330
+ if (isDeviceFatal()) {
331
+ const reason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
332
+ throw new DeviceUnhealthyError(
333
+ `Aborting test: device is fatally wedged — ${reason}. ` +
334
+ `It answers ECP/installer liveness probes but will not foreground a ` +
335
+ `sideloaded channel, so device recovery is skipped (it cannot help). ` +
336
+ `The device host must be recycled. Remaining tests in this worker ` +
337
+ `abort-fast. Per-failure diagnostics are written to ` +
338
+ `test-results/failures/<sanitised-test-path>/ — the FIRST failure in the ` +
339
+ `bundle carries the root cause.`,
340
+ reason
341
+ );
342
+ }
343
+ if (isDeviceHealthy()) return;
344
+ const reason = getUnhealthyReason() ?? "unknown";
345
+ // If `/query/device-info` answers, clear the flag and let
346
+ // `jest.retryTimes` re-execute the crashing test.
347
+ const waitResult = await tryWaitForDeviceOnline(recovery);
348
+ if (waitResult.ok) {
349
+ console.warn(
350
+ `[device-test-client] Device recovered after "${reason}" ` +
351
+ `(attempts=${waitResult.attempts}, elapsedMs=${waitResult.elapsedMs}). ` +
352
+ `Clearing unhealthy flag and continuing.`
353
+ );
354
+ resetDeviceHealth();
355
+ return;
356
+ }
357
+ // Fall through to the cascade error, but enrich it so the
358
+ // reviewer sees WHY recovery didn't help.
359
+ throw new DeviceUnhealthyError(
360
+ `Aborting test: device marked unhealthy — ${reason}. ` +
361
+ `Recovery attempted via waitForDeviceOnline and failed ` +
362
+ `after ${waitResult.elapsedMs}ms (${waitResult.attempts} probes): ` +
363
+ `${waitResult.errorMessage}. Remaining tests in this worker will ` +
364
+ `abort-fast. Per-failure diagnostics are written to ` +
365
+ `test-results/failures/<sanitised-test-path>/ — the ` +
366
+ `FIRST failure in the bundle carries the root cause.`,
367
+ reason
368
+ );
369
+ });
370
+ } else {
371
+ beforeEach(() => {
372
+ // Cross-file FATAL latch first (see the recovery branch above
373
+ // for why order matters): `isDeviceFatal()` rehydrates from the
374
+ // on-disk sentinel, so a wedge proved by an earlier test file
375
+ // aborts this fresh file even though its module-local
376
+ // `deviceHealthy` reset to `true`. The dedicated message names
377
+ // the wedge so triage doesn't mistake it for an ordinary
378
+ // cascade.
379
+ if (isDeviceFatal()) {
380
+ const fatalReason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
381
+ throw new DeviceUnhealthyError(
382
+ `Aborting test: device is fatally wedged — ${fatalReason}. ` +
383
+ `It answers ECP/installer liveness probes but will not foreground a ` +
384
+ `sideloaded channel; retrying cannot recover it. The device host ` +
385
+ `must be recycled. Remaining tests in this worker abort-fast. Per-failure ` +
386
+ `diagnostics are written to test-results/failures/<sanitised-test-path>/ ` +
387
+ `— the FIRST failure in the bundle carries the root cause.`,
388
+ fatalReason
389
+ );
390
+ }
391
+ if (isDeviceHealthy()) return;
392
+ // The cascade error's stack points HERE, not at the test
393
+ // that broke the device — surface the per-failure bundle
394
+ // path inline so reviewers find the root-cause artifacts.
395
+ const reason = getUnhealthyReason() ?? "unknown";
396
+ throw new DeviceUnhealthyError(
397
+ `Aborting test: device marked unhealthy — ${reason}. ` +
398
+ `Remaining tests in this worker will also abort-fast to ` +
399
+ `prevent cascading failures. Per-failure diagnostics ` +
400
+ `(screenshot, dom-tree, recent logs, error) are written to ` +
401
+ `test-results/failures/<sanitised-test-path>/ — the ` +
402
+ `FIRST failure in the bundle carries the root cause.`,
403
+ reason
404
+ );
405
+ });
406
+ }
407
+
408
+ afterEach(() => {
409
+ // No-op anchor. Per-test failure artifacts are written by
410
+ // `DeviceTestEnvironment.handleTestEvent` since `afterEach`
411
+ // cannot observe failure state without a side-channel flag.
412
+ });
413
+ }
414
+
415
+ /** Internal: fully-resolved recovery config. */
416
+ interface ResolvedRecoveryConfig {
417
+ enabled: boolean;
418
+ retryTimes: number;
419
+ waitForOnlineTimeoutMs: number;
420
+ pollIntervalMs: number;
421
+ client?: RkClient;
422
+ }
423
+
424
+ /**
425
+ * Normalise `deviceRecovery` into a concrete config. Opt-in: defaults
426
+ * OFF (legacy fast-fail cascade). "Enabled but no client" silently
427
+ * demotes to disabled rather than throwing — the client is often
428
+ * wired in later in the same `jest-setup.ts`.
429
+ */
430
+ function resolveRecoveryConfig(opts: InstallDeviceTestHooksOptions): ResolvedRecoveryConfig {
431
+ const client = opts.client;
432
+ const raw = opts.deviceRecovery;
433
+ if (raw === false || raw === undefined) {
434
+ return {
435
+ enabled: false,
436
+ retryTimes: 0,
437
+ waitForOnlineTimeoutMs: DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
438
+ pollIntervalMs: DEVICE_ONLINE_POLL_INTERVAL_MS,
439
+ client
440
+ };
441
+ }
442
+ const enabledRequested = raw.enabled ?? true;
443
+ const enabled = enabledRequested && client !== undefined;
444
+ return {
445
+ enabled,
446
+ retryTimes: raw.retryTimes ?? DEVICE_RECOVERY_DEFAULT_RETRIES,
447
+ waitForOnlineTimeoutMs: raw.waitForOnlineTimeoutMs ?? DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
448
+ pollIntervalMs: raw.pollIntervalMs ?? DEVICE_ONLINE_POLL_INTERVAL_MS,
449
+ client
450
+ };
451
+ }
452
+
453
+ interface RecoveryAttemptResult {
454
+ ok: boolean;
455
+ attempts: number;
456
+ elapsedMs: number;
457
+ errorMessage: string;
458
+ }
459
+
460
+ /**
461
+ * Wrap `waitForDeviceOnline` with a never-throw result shape so a
462
+ * recovery-layer bug cannot evict the original cascade message.
463
+ */
464
+ async function tryWaitForDeviceOnline(cfg: ResolvedRecoveryConfig): Promise<RecoveryAttemptResult> {
465
+ const startedAt = Date.now();
466
+ if (!cfg.client) {
467
+ return { ok: false, attempts: 0, elapsedMs: 0, errorMessage: "no client configured" };
468
+ }
469
+ try {
470
+ await cfg.client.waitForDeviceOnline({
471
+ timeoutMs: cfg.waitForOnlineTimeoutMs,
472
+ intervalMs: cfg.pollIntervalMs
473
+ });
474
+ return { ok: true, attempts: 1, elapsedMs: Date.now() - startedAt, errorMessage: "" };
475
+ } catch (err) {
476
+ const attempts =
477
+ typeof err === "object" && err && "context" in err
478
+ ? ((err as { context?: { attempts?: number } }).context?.attempts ?? 0)
479
+ : 0;
480
+ const message = err instanceof Error ? err.message : String(err);
481
+ return {
482
+ ok: false,
483
+ attempts,
484
+ elapsedMs: Date.now() - startedAt,
485
+ errorMessage: message
486
+ };
487
+ }
488
+ }
@@ -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";