@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,147 @@
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 { 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 deviceIp = opts.deviceIp ?? resolveDeviceIpSync(resultsDir);
34
+ const client = new RkClient({
35
+ ...opts,
36
+ deviceIp,
37
+ keyDelay: opts.keyDelay ?? envKeyDelay()
38
+ });
39
+
40
+ // If globalSetup already wrote a device-env.json snapshot, seed
41
+ // the worker-side cache from it so tests can call
42
+ // `client.cachedDeviceInfo()` without a second ECP round-trip.
43
+ //
44
+ // Schema-validate before seeding: a corrupted snapshot (truncated
45
+ // write, hand-edited junk, half-overwritten file) used to be cast
46
+ // to `DeviceInfo` and silently used to drive baseline selection
47
+ // (`hd` vs `fhd`) and precondition gating (`firmwareVersion`,
48
+ // `deviceClass`). The corrupted values then produced wrong
49
+ // baselines / wrong skip decisions WITHOUT a single warning,
50
+ // which is exactly the failure mode this gate is supposed to
51
+ // prevent.
52
+ //
53
+ // Failure modes are now distinguished:
54
+ // - file unreadable / not JSON → warn + skip seeding
55
+ // - JSON parses but shape is wrong → warn + skip seeding
56
+ // - JSON parses and matches `DeviceInfo`→ seed (happy path)
57
+ // In every case the explicit `queryDeviceInfo()` call still
58
+ // works, so we degrade to a slower-but-correct path rather than
59
+ // failing closed.
60
+ const envFile = join(resultsDir, ARTIFACT_FILE.DEVICE_ENV);
61
+ if (existsSync(envFile)) {
62
+ try {
63
+ const raw = readFileSync(envFile, "utf8");
64
+ const parsed = JSON.parse(raw);
65
+ const validation = validateDeviceInfo(parsed);
66
+ if (validation.ok) {
67
+ client.seedDeviceInfo(validation.value);
68
+ } else {
69
+ console.warn(
70
+ `[device-test-client] Ignoring corrupted device-env snapshot at "${envFile}": ` +
71
+ `${validation.reason}. Falling back to live queryDeviceInfo() — baseline ` +
72
+ `selection and preconditions will work but a stale/junk snapshot won't ` +
73
+ `silently mislead them.`
74
+ );
75
+ }
76
+ } catch (err) {
77
+ console.warn(
78
+ `[device-test-client] Failed to read device-env snapshot at "${envFile}": ` +
79
+ `${(err as Error).message}. Cache left empty.`
80
+ );
81
+ }
82
+ }
83
+
84
+ return client;
85
+ }
86
+
87
+ function envKeyDelay(): number {
88
+ const raw = process.env[ENV_VAR.KEY_DELAY];
89
+ const n = Number(raw);
90
+ return raw && Number.isFinite(n) && n > 0 ? n : DEFAULT_KEY_DELAY;
91
+ }
92
+
93
+ type ValidationResult<T> = { ok: true; value: T } | { ok: false; reason: string };
94
+
95
+ /**
96
+ * Narrow-checks a value against `DeviceInfo`. Hand-rolled (no zod)
97
+ * because this is the only place we validate this shape and the
98
+ * cost of an extra runtime dep is not worth it. Returns the typed
99
+ * value on success and a human-readable reason on failure so the
100
+ * warn-log is actionable.
101
+ *
102
+ * Required: every string field of `DeviceInfo` must be a non-empty
103
+ * string, and `libraries` must be a string→string record.
104
+ *
105
+ * Exported with an underscore-prefix because it's intentionally
106
+ * tested in isolation — callers should go through
107
+ * `createDefaultClient` which decides what to do on validation
108
+ * failure (currently: warn + skip seeding).
109
+ */
110
+ export function __validateDeviceInfoForTesting(value: unknown): ValidationResult<DeviceInfo> {
111
+ return validateDeviceInfo(value);
112
+ }
113
+
114
+ function validateDeviceInfo(value: unknown): ValidationResult<DeviceInfo> {
115
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
116
+ return {
117
+ ok: false,
118
+ reason: `expected an object, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`
119
+ };
120
+ }
121
+ const v = value as Record<string, unknown>;
122
+ const stringFields: ReadonlyArray<keyof DeviceInfo> = [
123
+ "modelName",
124
+ "modelNumber",
125
+ "modelFamily",
126
+ "deviceClass",
127
+ "firmwareVersion",
128
+ "videoMode",
129
+ "locale",
130
+ "country"
131
+ ];
132
+ for (const field of stringFields) {
133
+ const fv = v[field as string];
134
+ if (typeof fv !== "string" || fv.length === 0) {
135
+ return { ok: false, reason: `field "${String(field)}" must be a non-empty string (got ${typeof fv})` };
136
+ }
137
+ }
138
+ if (typeof v.libraries !== "object" || v.libraries === null || Array.isArray(v.libraries)) {
139
+ return { ok: false, reason: `field "libraries" must be a string→string object` };
140
+ }
141
+ for (const [k, libVal] of Object.entries(v.libraries as Record<string, unknown>)) {
142
+ if (typeof libVal !== "string") {
143
+ return { ok: false, reason: `libraries["${k}"] must be a string (got ${typeof libVal})` };
144
+ }
145
+ }
146
+ return { ok: true, value: v as unknown as DeviceInfo };
147
+ }
@@ -0,0 +1,214 @@
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
+ * Recycling is the CELL's job, not this process's. When
28
+ * `RK_DEVICE_FATAL_SENTINEL` names a path, `markDeviceFatal` drops
29
+ * a one-line JSON marker there the instant the latch flips. The CI
30
+ * cell script reads it AFTER Jest exits to tell an unrecoverable
31
+ * VM wedge (release the lease, re-acquire a fresh instance, retry
32
+ * once) apart from an ordinary test failure (propagate). The
33
+ * marker is opt-in via env so unit tests and local runs never
34
+ * touch the filesystem, and it lives OUTSIDE `test-results/`
35
+ * (which Jest's globalSetup wipes per run).
36
+ *
37
+ * Critically, `resetDeviceHealth()` clears ONLY the soft flag. The
38
+ * fatal latch deliberately survives it: the deploy-guard resets soft
39
+ * health between attempts, and if that also cleared the fatal latch
40
+ * the loop would keep retrying a VM that can never launch.
41
+ *
42
+ * Load-bearing: single-process execution (Jest `--runInBand`).
43
+ *
44
+ * CROSS-FILE LATCH. Jest sandboxes every test FILE in its own module
45
+ * registry — even under `--runInBand` (which only means "one worker,
46
+ * serial", NOT "shared module state"). So the in-memory `deviceFatal`
47
+ * singleton below RESETS to `false` at the start of every test file.
48
+ * Within a file the latch cascades fine; across files it would not,
49
+ * and each fresh file would re-pay the full deploy + recovery budget
50
+ * (~60s) rediscovering a wedge an earlier file already proved — the
51
+ * shard-wide, ~hour-long waste this module exists to eliminate.
52
+ *
53
+ * The ON-DISK sentinel (`RK_DEVICE_FATAL_SENTINEL`) is the only state
54
+ * that survives a module-registry reset, so it — not the in-memory
55
+ * flag — is the source of truth for the cross-file cascade.
56
+ * `markDeviceFatal` writes it on the first latch and `isDeviceFatal()`
57
+ * rehydrates the in-memory latch from it, so the first deploy in every
58
+ * subsequent file short-circuits in `beforeAll` instead of deploying.
59
+ * The CI cell still reads the same sentinel AFTER Jest exits to decide
60
+ * whether to recycle the lease.
61
+ *
62
+ * The only sanctioned mid-run reset is the `deviceRecovery` hook in
63
+ * `installDeviceTestHooks`, and it refuses to recover once the fatal
64
+ * latch is set. Forked workers would silently bypass the cascade.
65
+ */
66
+
67
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
68
+
69
+ let deviceHealthy = true;
70
+ let unhealthyReason: string | null = null;
71
+ let deviceFatal = false;
72
+ let fatalReason: string | null = null;
73
+
74
+ export function isDeviceHealthy(): boolean {
75
+ return deviceHealthy;
76
+ }
77
+
78
+ /**
79
+ * Flip the soft health flag, remembering the FIRST reason as root
80
+ * cause so cascade-detection messages don't obscure it.
81
+ */
82
+ export function markDeviceUnhealthy(reason: string): void {
83
+ if (!deviceHealthy) return;
84
+ deviceHealthy = false;
85
+ unhealthyReason = reason;
86
+ console.warn(`[device-test-client] device marked unhealthy: ${reason}`);
87
+ }
88
+
89
+ /** Last reason that flipped the soft health flag, or `null` if still healthy. */
90
+ export function getUnhealthyReason(): string | null {
91
+ return unhealthyReason;
92
+ }
93
+
94
+ /**
95
+ * Latch the device as FATALLY wedged: reachable on the network but
96
+ * structurally unable to foreground a sideloaded channel on this VM.
97
+ * Idempotent (first reason wins, single warn) like `markDeviceUnhealthy`.
98
+ *
99
+ * Also flips the soft health flag so the existing `beforeEach` cascade
100
+ * and `getUnhealthyReason()` consumers keep working unchanged — callers
101
+ * never need to call both. `resetDeviceHealth()` clears the soft flag
102
+ * but NOT this latch, so once a VM is declared wedged every subsequent
103
+ * deploy/recovery short-circuits instead of re-probing a dead instance.
104
+ */
105
+ export function markDeviceFatal(reason: string): void {
106
+ // Flip soft health first (idempotent; preserves an earlier soft
107
+ // reason as root cause) so the cascade keeps firing even if a
108
+ // softer fault was recorded before the fatal diagnosis.
109
+ markDeviceUnhealthy(reason);
110
+ if (deviceFatal) return;
111
+ deviceFatal = true;
112
+ fatalReason = reason;
113
+ // Drop the recycle marker exactly once, on the first latch, so the
114
+ // CI cell can re-acquire a fresh instance and retry (see doc-block).
115
+ writeFatalSentinel(reason);
116
+ console.warn(
117
+ `[device-test-client] device marked FATAL (unrecoverable on this VM): ${reason}. ` +
118
+ `The device answers ECP/installer probes but will not foreground a sideloaded ` +
119
+ `channel — recovery cannot cure this. The device host must be recycled; ` +
120
+ `remaining suites will skip their deploys instead of retrying.`
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Best-effort recycle marker for the CI cell. When
126
+ * `RK_DEVICE_FATAL_SENTINEL` names a path, write a one-line JSON marker
127
+ * the instant the fatal latch flips; the cell script reads it after
128
+ * Jest exits to decide whether to release + re-acquire a fresh instance
129
+ * and retry. Deliberately NOT exported — production code outside this
130
+ * module has no reason to forge a recycle signal.
131
+ *
132
+ * Opt-in via env so unit tests and local runs never touch the
133
+ * filesystem. A write failure is swallowed on purpose: the cascade
134
+ * `console.warn` and the thrown `DeviceUnhealthyError` already carry the
135
+ * reason, so a missing marker degrades to today's fail-fast behaviour
136
+ * rather than masking the underlying fatal.
137
+ */
138
+ function writeFatalSentinel(reason: string): void {
139
+ const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
140
+ if (!sentinelPath) return;
141
+ try {
142
+ writeFileSync(sentinelPath, `${JSON.stringify({ reason, ts: new Date().toISOString() })}\n`, "utf8");
143
+ } catch {
144
+ // best-effort — see doc-block.
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Rehydrate the in-memory fatal latch from the on-disk sentinel.
150
+ *
151
+ * Jest gives every test FILE a fresh module registry, so an earlier
152
+ * file's `markDeviceFatal` does NOT survive in `deviceFatal` here — only
153
+ * the sentinel it wrote to disk does. Reading it back is what makes the
154
+ * fatal latch a true CROSS-FILE cascade: the first deploy in every
155
+ * subsequent file sees `isDeviceFatal()` and short-circuits instead of
156
+ * paying the ~60s deploy + recovery budget to rediscover the same wedge.
157
+ *
158
+ * Once latched in-memory we never re-read (cheap, and the flag never
159
+ * un-sets within a process). A negative result is re-checked on each
160
+ * call because the sentinel can appear partway through the run.
161
+ * Existence is the signal; a malformed/unreadable file still latches
162
+ * with a fallback reason rather than letting the cascade leak.
163
+ */
164
+ function hydrateFatalFromSentinel(): void {
165
+ if (deviceFatal) return;
166
+ const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
167
+ if (!sentinelPath || !existsSync(sentinelPath)) return;
168
+ deviceFatal = true;
169
+ try {
170
+ const parsed = JSON.parse(readFileSync(sentinelPath, "utf8")) as { reason?: unknown };
171
+ fatalReason =
172
+ typeof parsed.reason === "string" && parsed.reason.length > 0
173
+ ? parsed.reason
174
+ : "device fatally wedged in a prior test file (see RK_DEVICE_FATAL_SENTINEL)";
175
+ } catch {
176
+ fatalReason = "device fatally wedged in a prior test file (see RK_DEVICE_FATAL_SENTINEL)";
177
+ }
178
+ }
179
+
180
+ /** `true` once the device has been latched as fatally wedged (this process or a prior test file). */
181
+ export function isDeviceFatal(): boolean {
182
+ hydrateFatalFromSentinel();
183
+ return deviceFatal;
184
+ }
185
+
186
+ /** Reason the device was latched fatal, or `null` if not fatal. */
187
+ export function getFatalReason(): string | null {
188
+ hydrateFatalFromSentinel();
189
+ return fatalReason;
190
+ }
191
+
192
+ /**
193
+ * Clear the SOFT health flag only. The fatal latch deliberately
194
+ * survives — see the module doc-block. The deploy-guard and recovery
195
+ * `beforeEach` call this between attempts; clearing the fatal latch
196
+ * here would defeat the whole fail-fast contract.
197
+ */
198
+ export function resetDeviceHealth(): void {
199
+ deviceHealthy = true;
200
+ unhealthyReason = null;
201
+ }
202
+
203
+ /**
204
+ * Test-only: clear BOTH the soft flag and the fatal latch so unit
205
+ * tests don't leak the process-lifetime fatal state across cases.
206
+ * NOT exported via `index.ts` — production code has no legitimate
207
+ * reason to un-wedge a device mid-run.
208
+ */
209
+ export function __resetDeviceHealthForTests(): void {
210
+ deviceHealthy = true;
211
+ unhealthyReason = null;
212
+ deviceFatal = false;
213
+ fatalReason = null;
214
+ }
@@ -0,0 +1,155 @@
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
+ /**
48
+ * Parse a SceneGraph bounds string `"{x, y, w, h}"`. Returns a
49
+ * zero rect on parse failure so callers don't branch on `null`.
50
+ */
51
+ export function parseBounds(raw: string | undefined): AppUiNode["bounds"] {
52
+ const zero = { x: 0, y: 0, width: 0, height: 0 };
53
+ if (!raw) return zero;
54
+ const match = /\{\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\}/.exec(raw);
55
+ if (!match) return zero;
56
+ return {
57
+ x: Number(match[1]),
58
+ y: Number(match[2]),
59
+ width: Number(match[3]),
60
+ height: Number(match[4])
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Map ECP `/query/device-info` to the `DeviceInfo` contract.
66
+ * - `modelName`: `model-name` then `friendly-model-name`.
67
+ * - `modelFamily`: `vendor-name` + `model-name` (ECP has no
68
+ * explicit family field).
69
+ * - `deviceClass`: `is-tv`/`is-stick` boolean flags → `tv|stick|player`.
70
+ * - `videoMode`: `video-mode` then `ui-resolution` (Streaming
71
+ * Stick 4K firmware 15.x lacks `video-mode`).
72
+ * - `libraries`: every `*-version` key not in `CORE_VERSION_FIELDS`.
73
+ */
74
+ export function parseDeviceInfo(raw: Record<string, string>): DeviceInfo {
75
+ const modelName = raw["model-name"] ?? raw["friendly-model-name"] ?? "";
76
+ const vendor = raw["vendor-name"] ?? "";
77
+ const modelFamily = vendor && modelName ? `${vendor} ${modelName}` : modelName || vendor;
78
+
79
+ let deviceClass: string;
80
+ if (raw["is-tv"] === "true") deviceClass = "tv";
81
+ else if (raw["is-stick"] === "true") deviceClass = "stick";
82
+ else deviceClass = "player";
83
+
84
+ return {
85
+ modelName,
86
+ modelNumber: raw["model-number"] ?? "",
87
+ modelFamily,
88
+ deviceClass,
89
+ firmwareVersion: raw["software-version"] ?? "",
90
+ videoMode: raw["video-mode"] ?? raw["ui-resolution"] ?? "",
91
+ locale: raw["locale"] ?? "",
92
+ country: raw["user-country"] ?? raw["country"] ?? "",
93
+ libraries: extractLibraries(raw)
94
+ };
95
+ }
96
+
97
+ const CORE_VERSION_FIELDS = new Set([
98
+ "software-version",
99
+ "software-build",
100
+ "build-version",
101
+ "udn-version",
102
+ "channel-version"
103
+ ]);
104
+
105
+ function extractLibraries(raw: Record<string, string>): Record<string, string> {
106
+ const out: Record<string, string> = {};
107
+ for (const [key, value] of Object.entries(raw)) {
108
+ if (!key.endsWith("-version")) continue;
109
+ if (CORE_VERSION_FIELDS.has(key)) continue;
110
+ const name = key.slice(0, -"-version".length);
111
+ if (name.length === 0) continue;
112
+ out[name] = value;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ export function parseActiveApp(root: NodeTuple): ActiveAppInfo {
118
+ const [, , children] = root;
119
+ for (const child of children) {
120
+ if (typeof child === "string") continue;
121
+ const [tag, attrs, inner] = child;
122
+ if (tag !== "app") continue;
123
+ let title = "";
124
+ for (const ic of inner) {
125
+ if (typeof ic === "string") {
126
+ title = ic.trim();
127
+ break;
128
+ }
129
+ }
130
+ return {
131
+ id: attrs.id ?? "",
132
+ title,
133
+ type: attrs.type ?? "",
134
+ uiLocation: attrs["ui-location"] ?? ""
135
+ };
136
+ }
137
+ return { id: "", title: "", type: "", uiLocation: "" };
138
+ }
139
+
140
+ /**
141
+ * True when the active app is a home surface rather than a channel.
142
+ *
143
+ * Firmware ships several. The classic home screen reports `<app>Roku</app>`
144
+ * with no attributes at all. native_tv builds instead report real apps:
145
+ * `native-ui` (`type="appl"`) while the transition settles, then Roku
146
+ * Dynamic Menu (`id="562859"`, `type="home"`). `ui-location` is what
147
+ * separates any of them from a channel, because a foregrounded channel
148
+ * reports its own id there and never `"home"`.
149
+ *
150
+ * The `id`/`title` arms are kept for firmware that omits both `type` and
151
+ * `ui-location`.
152
+ */
153
+ export function isHomeScreenActiveApp(active: ActiveAppInfo): boolean {
154
+ return active.type === "home" || active.uiLocation === "home" || active.id === "" || active.title === "Roku";
155
+ }
@@ -0,0 +1,137 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `resolveServerUrl` — single source of truth for where the
5
+ * `rk server` daemon lives, shared between `RkClient` construction
6
+ * and `globalSetup`/`globalTeardown`.
7
+ *
8
+ * Why a dedicated module instead of an inline helper? Jest's
9
+ * `globalTeardown` runs in a separate module graph from the worker
10
+ * processes that construct `RkClient`, so we need a tree-shake-safe
11
+ * import path that does not pull in the full `client.ts` surface
12
+ * (with its `pngjs`, `@roku-sdk/tooling/*` deps) into the teardown
13
+ * lifecycle. Isolating this helper keeps teardown lean and makes the
14
+ * resolution order easy to audit in one place.
15
+ *
16
+ * Resolution order (first non-empty wins):
17
+ * 1. `opts.serverUrl` — explicit override.
18
+ * 2. `process.env[RK_SERVER_URL]` — per-process env var.
19
+ * 3. `.roku/rk-server.json#apiEndpoint` — discovery file the
20
+ * `rk server` CLI writes on startup. The port is derived from a
21
+ * sha256 of the workspace path (see
22
+ * `@roku-sdk/tooling` `server/port-derivation.ts`), so the
23
+ * default `:3737` is almost never the right answer for a real
24
+ * developer box. Reading this file lets the fixture's log pillar
25
+ * "just work" after the user starts `rk server` — no manual
26
+ * `RK_SERVER_URL=…` export required.
27
+ * 4. `DEFAULT_RK_SERVER_URL` — compiled-in fallback.
28
+ *
29
+ * Discovery is intentionally best-effort: a torn read during a
30
+ * server restart, a missing file, or a malformed JSON payload all
31
+ * fall through silently to the next resolution tier.
32
+ *
33
+ * Output normalization. Whatever tier wins, the returned URL is
34
+ * normalized to end with `/api` (no trailing slash). All consumers
35
+ * (`LogSession.postStart`, `LogStreamClient`, `globalSetup`'s
36
+ * `resetAndStartLogSession`, `RkClient.getLogStreamHealth`) build
37
+ * request URLs by appending `/logs/...` to this base, and every
38
+ * route on the server is mounted under `/api/...`
39
+ * (`tooling/src/server/index.ts:387: app.use("/api/logs", logRoutes)`).
40
+ * The discovery file already emits the canonical `http://host:port/api`
41
+ * shape (`coordination.ts::createPortFileData`); the env var path
42
+ * historically allowed a bare `http://host:port` and silently 404'd.
43
+ * Normalizing here makes both tiers indistinguishable to consumers
44
+ * and means a developer can set `RK_SERVER_URL` without remembering
45
+ * the `/api` suffix.
46
+ */
47
+
48
+ import { existsSync, readFileSync } from "node:fs";
49
+ import { join, resolve as resolvePath } from "node:path";
50
+
51
+ import { DEFAULT_RK_SERVER_URL, ENV_VAR } from "../constants";
52
+
53
+ export interface ResolveServerUrlOptions {
54
+ /** Explicit override (e.g. from `RkClientOptions.serverUrl`). */
55
+ serverUrl?: string;
56
+ /**
57
+ * Directory to start the `.roku/rk-server.json` walk from. Defaults
58
+ * to `process.cwd()`; tests inject a synthetic start dir so they
59
+ * don't depend on the actual filesystem layout.
60
+ */
61
+ startDir?: string;
62
+ }
63
+
64
+ export function resolveServerUrl(opts: ResolveServerUrlOptions = {}): string {
65
+ return normalizeServerUrl(resolveRawServerUrl(opts));
66
+ }
67
+
68
+ function resolveRawServerUrl(opts: ResolveServerUrlOptions): string {
69
+ if (opts.serverUrl) return opts.serverUrl;
70
+ const envUrl = process.env[ENV_VAR.RK_SERVER_URL];
71
+ if (envUrl) return envUrl;
72
+ const discovered = readRkServerDiscovery(opts.startDir ?? process.cwd());
73
+ if (discovered) return discovered;
74
+ return DEFAULT_RK_SERVER_URL;
75
+ }
76
+
77
+ /**
78
+ * Coerce any of the historical accepted shapes —
79
+ * `http://host:port`, `http://host:port/`, `http://host:port/api`,
80
+ * `http://host:port/api/` — into the canonical
81
+ * `http://host:port/api` (no trailing slash). Idempotent. Exported
82
+ * for unit tests; production callers should always go through
83
+ * `resolveServerUrl`.
84
+ */
85
+ export function normalizeServerUrl(url: string): string {
86
+ const trimmed = url.endsWith("/") ? url.slice(0, -1) : url;
87
+ return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
88
+ }
89
+
90
+ /**
91
+ * Best-effort read of `.roku/rk-server.json#apiEndpoint` by walking
92
+ * up from `startDir` and looking for the file directly. Returns
93
+ * `null` on any failure.
94
+ *
95
+ * We look for the FILE (`.roku/rk-server.json`) rather than its
96
+ * containing directory (`.roku/`) because per-suite scaffolding
97
+ * often creates a local placeholder `.roku/` (e.g. `device-tests/
98
+ * .roku/` is a gitkept empty dir). Anchoring on the file — which is
99
+ * only written by `rk server` — sidesteps those false positives.
100
+ *
101
+ * Exported for unit tests — production code should go through
102
+ * `resolveServerUrl`.
103
+ */
104
+ export function readRkServerDiscovery(startDir: string): string | null {
105
+ const file = findRkServerConfigFile(startDir);
106
+ if (!file) return null;
107
+ try {
108
+ const raw = readFileSync(file, "utf8");
109
+ const parsed = JSON.parse(raw) as { apiEndpoint?: unknown };
110
+ if (typeof parsed.apiEndpoint === "string" && parsed.apiEndpoint) {
111
+ return parsed.apiEndpoint;
112
+ }
113
+ return null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Walk up from `startDir` looking for a `.roku/rk-server.json` file.
121
+ * Returns the absolute path to the first match, or `null` if we
122
+ * reach `/` first without finding one. The walk stops only at the
123
+ * filesystem root — we do NOT treat `.git` / `pnpm-workspace.yaml`
124
+ * as fences, because `rk server` typically runs at the outermost
125
+ * monorepo root while tests run from a package-level cwd several
126
+ * levels below, and we want them to find the outer config.
127
+ */
128
+ function findRkServerConfigFile(startDir: string): string | null {
129
+ let dir = resolvePath(startDir);
130
+ for (;;) {
131
+ const candidate = join(dir, ".roku", "rk-server.json");
132
+ if (existsSync(candidate)) return candidate;
133
+ const parent = resolvePath(dir, "..");
134
+ if (parent === dir) return null;
135
+ dir = parent;
136
+ }
137
+ }