@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.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/baselines.md +221 -0
- package/docs/configuration.md +258 -0
- package/docs/dom-assertions.md +264 -0
- package/docs/errors.md +525 -0
- package/docs/examples/deep-link-cold-start.md +85 -0
- package/docs/examples/focus-navigation.md +81 -0
- package/docs/examples/log-driven-assertion.md +99 -0
- package/docs/examples/precondition-skips.md +150 -0
- package/docs/examples/screenshot-regression.md +103 -0
- package/docs/examples/smoke-test.md +69 -0
- package/docs/getting-started.md +195 -0
- package/docs/jest-integration.md +331 -0
- package/docs/log-assertions.md +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -3
- package/src/assertions/assertions.ts +661 -0
- package/src/assertions/baseline-env-guard.ts +43 -0
- package/src/assertions/baselines.ts +178 -0
- package/src/assertions/screenshot-artifacts.ts +39 -0
- package/src/assertions/screenshot-diff.ts +447 -0
- package/src/client/build-cache.ts +292 -0
- package/src/client/client.ts +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -0
- package/src/device/device-class.ts +57 -0
- package/src/device/device-env-sync.ts +58 -0
- package/src/device/device-resolver-sync.ts +43 -0
- package/src/device/device-resolver.ts +141 -0
- package/src/device/lease-provisioning.ts +54 -0
- package/src/errors.ts +773 -0
- package/src/index.ts +186 -0
- package/src/internal/constants.ts +3 -0
- package/src/jest/artifacts.ts +87 -0
- package/src/jest/device-test-environment.ts +420 -0
- package/src/jest/failure-artifacts.ts +169 -0
- package/src/jest/global-setup.ts +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -0
- package/src/jest/jest-setup.ts +53 -0
- package/src/jest/preconditions.ts +239 -0
- package/src/jest/telemetry.ts +331 -0
- package/src/logs/device-log-sink.ts +69 -0
- package/src/logs/log-buffer.ts +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- 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
|
+
}
|