@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,170 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `createDefaultClient` — env-driven `RkClient` factory.
5
+ *
6
+ * Extracted from `client.ts` so the file containing the `RkClient`
7
+ * class stays focused on ECP + log/screenshot/DOM orchestration.
8
+ * `client.ts` re-exports `createDefaultClient` for public-API
9
+ * compatibility.
10
+ *
11
+ * Sync on purpose — see `device-resolver.ts` doc comment for the
12
+ * full rationale. The resolver consults, in order:
13
+ * 1. `RK_DEVICE_IP` env var, then
14
+ * 2. `<testResultsDir>/.device-ip` written by `globalSetup`.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { ARTIFACT_FILE, DEFAULT_KEY_DELAY, ENV_VAR } from "../constants";
21
+ import { resolveDeviceIpSync } from "../device/device-resolver-sync";
22
+ import { defaultTestResultsDir } from "../paths";
23
+ import type { DeploymentDriver, DeviceInfo, RkClientOptions } from "../types";
24
+ import { RkClient } from "./client";
25
+
26
+ /**
27
+ * Constructs an `RkClient` configured from process env. Single source of
28
+ * truth for client construction; used by both `setup.ts` (worker-side)
29
+ * and `global-setup.ts` (host-side).
30
+ */
31
+ export function createDefaultClient(opts: RkClientOptions = {}): RkClient {
32
+ const resultsDir = defaultTestResultsDir();
33
+ const driver = opts.driver ?? loadDriverFromEnv();
34
+ const deviceIp = driver ? opts.deviceIp : (opts.deviceIp ?? resolveDeviceIpSync(resultsDir));
35
+ const client = new RkClient({
36
+ ...opts,
37
+ driver,
38
+ deviceIp,
39
+ keyDelay: opts.keyDelay ?? envKeyDelay()
40
+ });
41
+
42
+ // If globalSetup already wrote a device-env.json snapshot, seed
43
+ // the worker-side cache from it so tests can call
44
+ // `client.cachedDeviceInfo()` without a second ECP round-trip.
45
+ //
46
+ // Schema-validate before seeding: a corrupted snapshot (truncated
47
+ // write, hand-edited junk, half-overwritten file) used to be cast
48
+ // to `DeviceInfo` and silently used to drive baseline selection
49
+ // (`hd` vs `fhd`) and precondition gating (`firmwareVersion`,
50
+ // `deviceClass`). The corrupted values then produced wrong
51
+ // baselines / wrong skip decisions WITHOUT a single warning,
52
+ // which is exactly the failure mode this gate is supposed to
53
+ // prevent.
54
+ //
55
+ // Failure modes are now distinguished:
56
+ // - file unreadable / not JSON → warn + skip seeding
57
+ // - JSON parses but shape is wrong → warn + skip seeding
58
+ // - JSON parses and matches `DeviceInfo`→ seed (happy path)
59
+ // In every case the explicit `queryDeviceInfo()` call still
60
+ // works, so we degrade to a slower-but-correct path rather than
61
+ // failing closed.
62
+ const envFile = join(resultsDir, ARTIFACT_FILE.DEVICE_ENV);
63
+ if (existsSync(envFile)) {
64
+ try {
65
+ const raw = readFileSync(envFile, "utf8");
66
+ const parsed = JSON.parse(raw);
67
+ const validation = validateDeviceInfo(parsed);
68
+ if (validation.ok) {
69
+ client.seedDeviceInfo(validation.value);
70
+ } else {
71
+ console.warn(
72
+ `[device-test-client] Ignoring corrupted device-env snapshot at "${envFile}": ` +
73
+ `${validation.reason}. Falling back to live queryDeviceInfo() — baseline ` +
74
+ `selection and preconditions will work but a stale/junk snapshot won't ` +
75
+ `silently mislead them.`
76
+ );
77
+ }
78
+ } catch (err) {
79
+ console.warn(
80
+ `[device-test-client] Failed to read device-env snapshot at "${envFile}": ` +
81
+ `${(err as Error).message}. Cache left empty.`
82
+ );
83
+ }
84
+ }
85
+
86
+ return client;
87
+ }
88
+
89
+ /**
90
+ * `RK_DEVICE_DRIVER_MODULE` names a module providing the deployment driver
91
+ * (see `DeploymentDriver`). Loaded synchronously: client construction is sync.
92
+ */
93
+ function loadDriverFromEnv(): DeploymentDriver | undefined {
94
+ const modulePath = process.env[ENV_VAR.RK_DEVICE_DRIVER_MODULE];
95
+ if (!modulePath) return undefined;
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
97
+ const mod = require(modulePath) as {
98
+ createDeploymentDriver?: () => DeploymentDriver;
99
+ default?: () => DeploymentDriver;
100
+ };
101
+ const factory = mod.createDeploymentDriver ?? mod.default;
102
+ if (typeof factory !== "function") {
103
+ throw new Error(
104
+ `${ENV_VAR.RK_DEVICE_DRIVER_MODULE}=${modulePath} must export createDeploymentDriver() (or a default factory).`
105
+ );
106
+ }
107
+ return factory();
108
+ }
109
+
110
+ function envKeyDelay(): number {
111
+ const raw = process.env[ENV_VAR.KEY_DELAY];
112
+ const n = Number(raw);
113
+ return raw && Number.isFinite(n) && n > 0 ? n : DEFAULT_KEY_DELAY;
114
+ }
115
+
116
+ type ValidationResult<T> = { ok: true; value: T } | { ok: false; reason: string };
117
+
118
+ /**
119
+ * Narrow-checks a value against `DeviceInfo`. Hand-rolled (no zod)
120
+ * because this is the only place we validate this shape and the
121
+ * cost of an extra runtime dep is not worth it. Returns the typed
122
+ * value on success and a human-readable reason on failure so the
123
+ * warn-log is actionable.
124
+ *
125
+ * Required: every string field of `DeviceInfo` must be a non-empty
126
+ * string, and `libraries` must be a string→string record.
127
+ *
128
+ * Exported with an underscore-prefix because it's intentionally
129
+ * tested in isolation — callers should go through
130
+ * `createDefaultClient` which decides what to do on validation
131
+ * failure (currently: warn + skip seeding).
132
+ */
133
+ export function __validateDeviceInfoForTesting(value: unknown): ValidationResult<DeviceInfo> {
134
+ return validateDeviceInfo(value);
135
+ }
136
+
137
+ function validateDeviceInfo(value: unknown): ValidationResult<DeviceInfo> {
138
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
139
+ return {
140
+ ok: false,
141
+ reason: `expected an object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`
142
+ };
143
+ }
144
+ const v = value as Record<string, unknown>;
145
+ const stringFields: ReadonlyArray<keyof DeviceInfo> = [
146
+ "modelName",
147
+ "modelNumber",
148
+ "modelFamily",
149
+ "deviceClass",
150
+ "firmwareVersion",
151
+ "videoMode",
152
+ "locale",
153
+ "country"
154
+ ];
155
+ for (const field of stringFields) {
156
+ const fv = v[field as string];
157
+ if (typeof fv !== "string" || fv.length === 0) {
158
+ return { ok: false, reason: `field "${String(field)}" must be a non-empty string (got ${typeof fv})` };
159
+ }
160
+ }
161
+ if (typeof v.libraries !== "object" || v.libraries === null || Array.isArray(v.libraries)) {
162
+ return { ok: false, reason: `field "libraries" must be a string→string object` };
163
+ }
164
+ for (const [k, libVal] of Object.entries(v.libraries as Record<string, unknown>)) {
165
+ if (typeof libVal !== "string") {
166
+ return { ok: false, reason: `libraries["${k}"] must be a string (got ${typeof libVal})` };
167
+ }
168
+ }
169
+ return { ok: true, value: v as unknown as DeviceInfo };
170
+ }
@@ -0,0 +1,345 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Per-process device-health flags. Tests that detect a system
5
+ * dialog, crash, or unrecoverable condition flip them so subsequent
6
+ * tests cascade-abort in `beforeEach`.
7
+ *
8
+ * Two distinct flags, intentionally separate:
9
+ *
10
+ * 1. SOFT health (`deviceHealthy`) — flipped by `markDeviceUnhealthy`,
11
+ * cleared by `resetDeviceHealth`. Models a *recoverable* fault:
12
+ * a crash/reboot the device can come back from. The deploy-guard
13
+ * retry loop and `installDeviceTestHooks`' recovery `beforeEach`
14
+ * both clear it after a successful liveness probe.
15
+ *
16
+ * 2. FATAL latch (`deviceFatal`) — flipped by `markDeviceFatal`,
17
+ * NEVER cleared in production (only the test-only reset clears it).
18
+ * Models an *unrecoverable-on-this-VM* fault: the device answers
19
+ * ECP and the sideload installer, but it refuses to foreground a
20
+ * freshly-sideloaded dev channel ("never left home screen"). No
21
+ * amount of `waitForDeviceFullyReady` / reboot-wait can cure that
22
+ * from inside the test process — the device host has to be
23
+ * recycled. Retrying on the wedged VM only burns the per-attempt
24
+ * budget × every remaining suite (the ~20-minute waste this latch
25
+ * exists to eliminate).
26
+ *
27
+ * Because the latch is this expensive to get wrong — it fails the
28
+ * whole shard and recycles the lease — it requires the device to
29
+ * have no record of ever foregrounding a channel, and the failing
30
+ * deploy's device log to show no launch of its own. A deploy that saw
31
+ * one on screen calls `markChannelForegrounded`, and from then on a
32
+ * stalled launch on that VM is reported as the soft, recoverable
33
+ * fault it is indistinguishable from. A deploy whose log shows its
34
+ * channel entering is soft for the same reason: the VM can start
35
+ * one. Both stay soft only until `noteForegroundFailure`'s streak
36
+ * reaches its limit.
37
+ *
38
+ * Recycling is the CELL's job, not this process's. When
39
+ * `RK_DEVICE_FATAL_SENTINEL` names a path, `markDeviceFatal` drops
40
+ * a one-line JSON marker there the instant the latch flips. The CI
41
+ * cell script reads it AFTER Jest exits to tell an unrecoverable
42
+ * VM wedge (release the lease, re-acquire a fresh instance, retry
43
+ * once) apart from an ordinary test failure (propagate). The
44
+ * marker is opt-in via env so unit tests and local runs never
45
+ * touch the filesystem, and it lives OUTSIDE `test-results/`
46
+ * (which Jest's globalSetup wipes per run).
47
+ *
48
+ * Critically, `resetDeviceHealth()` clears ONLY the soft flag. The
49
+ * fatal latch deliberately survives it: the deploy-guard resets soft
50
+ * health between attempts, and if that also cleared the fatal latch
51
+ * the loop would keep retrying a VM that can never launch.
52
+ *
53
+ * Load-bearing: single-process execution (Jest `--runInBand`).
54
+ *
55
+ * CROSS-FILE LATCH. Jest sandboxes every test FILE in its own module
56
+ * registry — even under `--runInBand` (which only means "one worker,
57
+ * serial", NOT "shared module state"). So the in-memory `deviceFatal`
58
+ * singleton below RESETS to `false` at the start of every test file.
59
+ * Within a file the latch cascades fine; across files it would not,
60
+ * and each fresh file would re-pay the full deploy + recovery budget
61
+ * (~60s) rediscovering a wedge an earlier file already proved — the
62
+ * shard-wide, ~hour-long waste this module exists to eliminate.
63
+ *
64
+ * The ON-DISK sentinel (`RK_DEVICE_FATAL_SENTINEL`) is the only state
65
+ * that survives a module-registry reset, so it — not the in-memory
66
+ * flag — is the source of truth for the cross-file cascade.
67
+ * `markDeviceFatal` writes it on the first latch and `isDeviceFatal()`
68
+ * rehydrates the in-memory latch from it, so the first deploy in every
69
+ * subsequent file short-circuits in `beforeAll` instead of deploying.
70
+ * The CI cell still reads the same sentinel AFTER Jest exits to decide
71
+ * whether to recycle the lease.
72
+ *
73
+ * The only sanctioned mid-run reset is the `deviceRecovery` hook in
74
+ * `installDeviceTestHooks`, and it refuses to recover once the fatal
75
+ * latch is set. Forked workers would silently bypass the cascade.
76
+ */
77
+
78
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
79
+
80
+ let deviceHealthy = true;
81
+ let unhealthyReason: string | null = null;
82
+ let deviceFatal = false;
83
+ let fatalReason: string | null = null;
84
+ let foregroundedDeviceIp: string | null = null;
85
+ /** Deploys in a row that failed to foreground the channel; reset by any success. */
86
+ let foregroundFailureStreak = 0;
87
+
88
+ export function isDeviceHealthy(): boolean {
89
+ return deviceHealthy;
90
+ }
91
+
92
+ /**
93
+ * Flip the soft health flag, remembering the FIRST reason as root
94
+ * cause so cascade-detection messages don't obscure it.
95
+ */
96
+ export function markDeviceUnhealthy(reason: string): void {
97
+ if (!deviceHealthy) return;
98
+ deviceHealthy = false;
99
+ unhealthyReason = reason;
100
+ console.warn(`[device-test-client] device marked unhealthy: ${reason}`);
101
+ }
102
+
103
+ /** Last reason that flipped the soft health flag, or `null` if still healthy. */
104
+ export function getUnhealthyReason(): string | null {
105
+ return unhealthyReason;
106
+ }
107
+
108
+ /**
109
+ * Record that `deviceIp` foregrounded a sideloaded channel — a deploy
110
+ * saw one on screen, so the VM is structurally capable of launching
111
+ * one. Keyed by IP because that is the granularity the fatal
112
+ * latch claims ("unrecoverable on THIS VM"): a re-leased slot is a
113
+ * different instance and starts with no history.
114
+ *
115
+ * Persisted beside the recycle sentinel for the same reason it exists —
116
+ * Jest's per-file module registry reset (see doc-block) would otherwise
117
+ * drop the evidence between test files, which is exactly where the
118
+ * misdiagnosis it prevents occurs: one file foregrounds the app, the
119
+ * next file's deploy stalls and, remembering nothing, declares the VM
120
+ * incapable of what the previous file just watched it do.
121
+ */
122
+ export function markChannelForegrounded(deviceIp: string): void {
123
+ if (foregroundedDeviceIp === deviceIp && foregroundFailureStreak === 0) return;
124
+ foregroundedDeviceIp = deviceIp;
125
+ // A channel on screen ends whatever streak was building: the next
126
+ // failure starts counting from a VM that just demonstrably worked.
127
+ foregroundFailureStreak = 0;
128
+ writeLaunchEvidence(deviceIp);
129
+ }
130
+
131
+ /**
132
+ * Record a deploy that never got the channel on screen, and report how
133
+ * many have now failed in a row on `deviceIp`.
134
+ *
135
+ * Launch history alone cannot separate a bad launch from a VM that
136
+ * worked and then died, because both look identical from inside the
137
+ * failing deploy. The streak separates them over time: a transient
138
+ * stall is followed by a channel on screen, which resets it, while a
139
+ * dead VM only ever adds to it. Persisted beside the launch evidence
140
+ * and for the same reason — Jest's per-file registry reset would
141
+ * otherwise restart the count in every file, which is precisely the
142
+ * span over which a wedge has to be recognised.
143
+ */
144
+ export function noteForegroundFailure(deviceIp: string): number {
145
+ if (foregroundedDeviceIp !== deviceIp) {
146
+ hydrateLaunchEvidenceFor(deviceIp);
147
+ }
148
+ foregroundFailureStreak += 1;
149
+ writeLaunchEvidence(deviceIp);
150
+ return foregroundFailureStreak;
151
+ }
152
+
153
+ /** `true` once `deviceIp` has been seen foregrounding a sideloaded channel (this process or a prior test file). */
154
+ export function hasForegroundedChannel(deviceIp: string): boolean {
155
+ if (foregroundedDeviceIp !== deviceIp) {
156
+ hydrateLaunchEvidenceFor(deviceIp);
157
+ }
158
+ return foregroundedDeviceIp === deviceIp;
159
+ }
160
+
161
+ /**
162
+ * Latch the device as FATALLY wedged: reachable on the network but
163
+ * structurally unable to foreground a sideloaded channel on this VM.
164
+ * Idempotent (first reason wins, single warn) like `markDeviceUnhealthy`.
165
+ *
166
+ * Also flips the soft health flag so the existing `beforeEach` cascade
167
+ * and `getUnhealthyReason()` consumers keep working unchanged — callers
168
+ * never need to call both. `resetDeviceHealth()` clears the soft flag
169
+ * but NOT this latch, so once a VM is declared wedged every subsequent
170
+ * deploy/recovery short-circuits instead of re-probing a dead instance.
171
+ */
172
+ export function markDeviceFatal(reason: string): void {
173
+ // Flip soft health first (idempotent; preserves an earlier soft
174
+ // reason as root cause) so the cascade keeps firing even if a
175
+ // softer fault was recorded before the fatal diagnosis.
176
+ markDeviceUnhealthy(reason);
177
+ if (deviceFatal) return;
178
+ deviceFatal = true;
179
+ fatalReason = reason;
180
+ // Drop the recycle marker exactly once, on the first latch, so the
181
+ // CI cell can re-acquire a fresh instance and retry (see doc-block).
182
+ writeFatalSentinel(reason);
183
+ console.warn(
184
+ `[device-test-client] device marked FATAL (unrecoverable on this VM): ${reason}. ` +
185
+ `The device answers ECP/installer probes but will not foreground a sideloaded ` +
186
+ `channel — recovery cannot cure this. The device host must be recycled; ` +
187
+ `remaining suites will skip their deploys instead of retrying.`
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Best-effort recycle marker for the CI cell. When
193
+ * `RK_DEVICE_FATAL_SENTINEL` names a path, write a one-line JSON marker
194
+ * the instant the fatal latch flips; the cell script reads it after
195
+ * Jest exits to decide whether to release + re-acquire a fresh instance
196
+ * and retry. Deliberately NOT exported — production code outside this
197
+ * module has no reason to forge a recycle signal.
198
+ *
199
+ * Opt-in via env so unit tests and local runs never touch the
200
+ * filesystem. A write failure is swallowed on purpose: the cascade
201
+ * `console.warn` and the thrown `DeviceUnhealthyError` already carry the
202
+ * reason, so a missing marker degrades to today's fail-fast behaviour
203
+ * rather than masking the underlying fatal.
204
+ */
205
+ function writeFatalSentinel(reason: string): void {
206
+ const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
207
+ if (!sentinelPath) return;
208
+ try {
209
+ writeFileSync(sentinelPath, `${JSON.stringify({ reason, ts: new Date().toISOString() })}\n`, "utf8");
210
+ } catch {
211
+ // best-effort — see doc-block.
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Path of the launch-evidence marker: a sibling of the recycle sentinel,
217
+ * so one env var configures the whole wedge-classification state and the
218
+ * two files share a lifetime. `undefined` (env unset) keeps local runs
219
+ * and unit tests off the filesystem, at the cost of evidence that does
220
+ * not cross test files — the same opt-in trade the sentinel makes.
221
+ *
222
+ * The marker records the IP it was written for, so a file left behind by
223
+ * an earlier job on a reused workspace is inert: a later lease answers on
224
+ * a different IP and the evidence simply does not match.
225
+ */
226
+ function launchEvidencePath(): string | undefined {
227
+ const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
228
+ return sentinelPath ? `${sentinelPath}.launched` : undefined;
229
+ }
230
+
231
+ /** Best-effort, like `writeFatalSentinel`: a lost marker degrades to today's stricter verdict, never to a missed wedge. */
232
+ function writeLaunchEvidence(deviceIp: string): void {
233
+ const path = launchEvidencePath();
234
+ if (!path) return;
235
+ try {
236
+ const record = {
237
+ deviceIp,
238
+ ts: new Date().toISOString(),
239
+ foregrounded: foregroundedDeviceIp === deviceIp,
240
+ failures: foregroundFailureStreak
241
+ };
242
+ writeFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
243
+ } catch {
244
+ // best-effort — see doc-block.
245
+ }
246
+ }
247
+
248
+ /** Adopt the on-disk launch evidence when it was recorded for `deviceIp`. */
249
+ function hydrateLaunchEvidenceFor(deviceIp: string): void {
250
+ const path = launchEvidencePath();
251
+ if (!path || !existsSync(path)) return;
252
+ try {
253
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
254
+ deviceIp?: unknown;
255
+ foregrounded?: unknown;
256
+ failures?: unknown;
257
+ };
258
+ if (parsed.deviceIp === deviceIp) {
259
+ // A streak can be recorded for a device that has never put a
260
+ // channel on screen; only an explicit `false` says so.
261
+ if (parsed.foregrounded !== false) {
262
+ foregroundedDeviceIp = deviceIp;
263
+ }
264
+ // A marker written before the streak existed, or with a
265
+ // junk count, reads as no failures — the stricter verdict
266
+ // is the fatal one, so an unreadable count must not latch it.
267
+ foregroundFailureStreak =
268
+ typeof parsed.failures === "number" && Number.isFinite(parsed.failures) && parsed.failures > 0
269
+ ? Math.floor(parsed.failures)
270
+ : 0;
271
+ }
272
+ } catch {
273
+ // Unreadable evidence proves nothing; leave the stricter verdict in place.
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Rehydrate the in-memory fatal latch from the on-disk sentinel.
279
+ *
280
+ * Jest gives every test FILE a fresh module registry, so an earlier
281
+ * file's `markDeviceFatal` does NOT survive in `deviceFatal` here — only
282
+ * the sentinel it wrote to disk does. Reading it back is what makes the
283
+ * fatal latch a true CROSS-FILE cascade: the first deploy in every
284
+ * subsequent file sees `isDeviceFatal()` and short-circuits instead of
285
+ * paying the ~60s deploy + recovery budget to rediscover the same wedge.
286
+ *
287
+ * Once latched in-memory we never re-read (cheap, and the flag never
288
+ * un-sets within a process). A negative result is re-checked on each
289
+ * call because the sentinel can appear partway through the run.
290
+ * Existence is the signal; a malformed/unreadable file still latches
291
+ * with a fallback reason rather than letting the cascade leak.
292
+ */
293
+ function hydrateFatalFromSentinel(): void {
294
+ if (deviceFatal) return;
295
+ const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
296
+ if (!sentinelPath || !existsSync(sentinelPath)) return;
297
+ deviceFatal = true;
298
+ try {
299
+ const parsed = JSON.parse(readFileSync(sentinelPath, "utf8")) as { reason?: unknown };
300
+ fatalReason =
301
+ typeof parsed.reason === "string" && parsed.reason.length > 0
302
+ ? parsed.reason
303
+ : "device fatally wedged in a prior test file (see RK_DEVICE_FATAL_SENTINEL)";
304
+ } catch {
305
+ fatalReason = "device fatally wedged in a prior test file (see RK_DEVICE_FATAL_SENTINEL)";
306
+ }
307
+ }
308
+
309
+ /** `true` once the device has been latched as fatally wedged (this process or a prior test file). */
310
+ export function isDeviceFatal(): boolean {
311
+ hydrateFatalFromSentinel();
312
+ return deviceFatal;
313
+ }
314
+
315
+ /** Reason the device was latched fatal, or `null` if not fatal. */
316
+ export function getFatalReason(): string | null {
317
+ hydrateFatalFromSentinel();
318
+ return fatalReason;
319
+ }
320
+
321
+ /**
322
+ * Clear the SOFT health flag only. The fatal latch deliberately
323
+ * survives — see the module doc-block. The deploy-guard and recovery
324
+ * `beforeEach` call this between attempts; clearing the fatal latch
325
+ * here would defeat the whole fail-fast contract.
326
+ */
327
+ export function resetDeviceHealth(): void {
328
+ deviceHealthy = true;
329
+ unhealthyReason = null;
330
+ }
331
+
332
+ /**
333
+ * Test-only: clear BOTH the soft flag and the fatal latch so unit
334
+ * tests don't leak the process-lifetime fatal state across cases.
335
+ * NOT exported via `index.ts` — production code has no legitimate
336
+ * reason to un-wedge a device mid-run.
337
+ */
338
+ export function __resetDeviceHealthForTests(): void {
339
+ deviceHealthy = true;
340
+ unhealthyReason = null;
341
+ deviceFatal = false;
342
+ fatalReason = null;
343
+ foregroundedDeviceIp = null;
344
+ foregroundFailureStreak = 0;
345
+ }
@@ -0,0 +1,162 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Pure parsing helpers for ECP XML responses. Extracted from
5
+ * `client.ts` so they can be unit-tested without an `RkClient`.
6
+ * No network, no fs, no module state.
7
+ */
8
+
9
+ import type { ActiveAppInfo, AppUiNode, DeviceInfo } from "../types";
10
+
11
+ /**
12
+ * Tuple tree produced by `xmlToJson`. Tooling types children as
13
+ * `unknown[]`; we narrow at each recursion step.
14
+ */
15
+ export type NodeTuple = [string, Record<string, string>, Array<NodeTuple | string>];
16
+
17
+ /** Runtime cast of the `xmlToJson` return into our strict `NodeTuple` shape. */
18
+ export function asNodeTuple(
19
+ raw: [string, Record<string, string>, Array<[string, Record<string, string>, unknown[]] | string>] | null
20
+ ): NodeTuple | null {
21
+ return raw as NodeTuple | null;
22
+ }
23
+
24
+ export function tupleToAppUiNode(tuple: NodeTuple): AppUiNode {
25
+ const [tag, attrs, rawChildren] = tuple;
26
+ const children: AppUiNode[] = [];
27
+ for (const child of rawChildren) {
28
+ // Skip text nodes (whitespace or stray label literals).
29
+ if (typeof child !== "string") children.push(tupleToAppUiNode(child));
30
+ }
31
+ // ECP serializes SceneGraph `id` as XML `name=`; mirror it back
32
+ // to `id` so JSX-style selectors work. Raw `attrs.name` still
33
+ // available for callers that want it.
34
+ const normalizedAttrs = { ...attrs };
35
+ if (normalizedAttrs.id === undefined && normalizedAttrs.name !== undefined) {
36
+ normalizedAttrs.id = normalizedAttrs.name;
37
+ }
38
+ return {
39
+ tag,
40
+ attrs: normalizedAttrs,
41
+ bounds: parseBounds(attrs.bounds),
42
+ focused: attrs.focused === "true",
43
+ children
44
+ };
45
+ }
46
+
47
+ const BOUNDS_NUMBER = String.raw`(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)`;
48
+ const BOUNDS_PATTERN = new RegExp(
49
+ String.raw`\{\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*\}`
50
+ );
51
+
52
+ /**
53
+ * Parse a SceneGraph bounds string `"{x, y, w, h}"`. Values are
54
+ * returned unrounded: a 720p device reports FHD-canvas bounds in
55
+ * float32, e.g. `"{250, 899.99994, 10, 10}"`. Returns a zero rect on
56
+ * parse failure so callers don't branch on `null`.
57
+ */
58
+ export function parseBounds(raw: string | undefined): AppUiNode["bounds"] {
59
+ const zero = { x: 0, y: 0, width: 0, height: 0 };
60
+ if (!raw) return zero;
61
+ const match = BOUNDS_PATTERN.exec(raw);
62
+ if (!match) return zero;
63
+ return {
64
+ x: Number(match[1]),
65
+ y: Number(match[2]),
66
+ width: Number(match[3]),
67
+ height: Number(match[4])
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Map ECP `/query/device-info` to the `DeviceInfo` contract.
73
+ * - `modelName`: `model-name` then `friendly-model-name`.
74
+ * - `modelFamily`: `vendor-name` + `model-name` (ECP has no
75
+ * explicit family field).
76
+ * - `deviceClass`: `is-tv`/`is-stick` boolean flags → `tv|stick|player`.
77
+ * - `videoMode`: `video-mode` then `ui-resolution` (Streaming
78
+ * Stick 4K firmware 15.x lacks `video-mode`).
79
+ * - `libraries`: every `*-version` key not in `CORE_VERSION_FIELDS`.
80
+ */
81
+ export function parseDeviceInfo(raw: Record<string, string>): DeviceInfo {
82
+ const modelName = raw["model-name"] ?? raw["friendly-model-name"] ?? "";
83
+ const vendor = raw["vendor-name"] ?? "";
84
+ const modelFamily = vendor && modelName ? `${vendor} ${modelName}` : modelName || vendor;
85
+
86
+ let deviceClass: string;
87
+ if (raw["is-tv"] === "true") deviceClass = "tv";
88
+ else if (raw["is-stick"] === "true") deviceClass = "stick";
89
+ else deviceClass = "player";
90
+
91
+ return {
92
+ modelName,
93
+ modelNumber: raw["model-number"] ?? "",
94
+ modelFamily,
95
+ deviceClass,
96
+ firmwareVersion: raw["software-version"] ?? "",
97
+ videoMode: raw["video-mode"] ?? raw["ui-resolution"] ?? "",
98
+ locale: raw["locale"] ?? "",
99
+ country: raw["user-country"] ?? raw["country"] ?? "",
100
+ libraries: extractLibraries(raw)
101
+ };
102
+ }
103
+
104
+ const CORE_VERSION_FIELDS = new Set([
105
+ "software-version",
106
+ "software-build",
107
+ "build-version",
108
+ "udn-version",
109
+ "channel-version"
110
+ ]);
111
+
112
+ function extractLibraries(raw: Record<string, string>): Record<string, string> {
113
+ const out: Record<string, string> = {};
114
+ for (const [key, value] of Object.entries(raw)) {
115
+ if (!key.endsWith("-version")) continue;
116
+ if (CORE_VERSION_FIELDS.has(key)) continue;
117
+ const name = key.slice(0, -"-version".length);
118
+ if (name.length === 0) continue;
119
+ out[name] = value;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ export function parseActiveApp(root: NodeTuple): ActiveAppInfo {
125
+ const [, , children] = root;
126
+ for (const child of children) {
127
+ if (typeof child === "string") continue;
128
+ const [tag, attrs, inner] = child;
129
+ if (tag !== "app") continue;
130
+ let title = "";
131
+ for (const ic of inner) {
132
+ if (typeof ic === "string") {
133
+ title = ic.trim();
134
+ break;
135
+ }
136
+ }
137
+ return {
138
+ id: attrs.id ?? "",
139
+ title,
140
+ type: attrs.type ?? "",
141
+ uiLocation: attrs["ui-location"] ?? ""
142
+ };
143
+ }
144
+ return { id: "", title: "", type: "", uiLocation: "" };
145
+ }
146
+
147
+ /**
148
+ * True when the active app is a home surface rather than a channel.
149
+ *
150
+ * Firmware ships several. The classic home screen reports `<app>Roku</app>`
151
+ * with no attributes at all. native_tv builds instead report real apps:
152
+ * `native-ui` (`type="appl"`) while the transition settles, then Roku
153
+ * Dynamic Menu (`id="562859"`, `type="home"`). `ui-location` is what
154
+ * separates any of them from a channel, because a foregrounded channel
155
+ * reports its own id there and never `"home"`.
156
+ *
157
+ * The `id`/`title` arms are kept for firmware that omits both `type` and
158
+ * `ui-location`.
159
+ */
160
+ export function isHomeScreenActiveApp(active: ActiveAppInfo): boolean {
161
+ return active.type === "home" || active.uiLocation === "home" || active.id === "" || active.title === "Roku";
162
+ }