@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.
- package/BREAKING_CHANGES.md +17 -0
- 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 +544 -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 +349 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +657 -0
- package/docs/screenshot-assertions.md +316 -0
- package/package.json +81 -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 +1964 -0
- package/src/client/deploy.ts +407 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +170 -0
- package/src/client/health.ts +345 -0
- package/src/client/parsers.ts +162 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +520 -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 +774 -0
- package/src/index.ts +181 -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 +137 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +490 -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 +278 -0
- package/src/logs/log-session.ts +436 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +472 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- 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
|
+
}
|