@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,239 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Precondition assertions.
|
|
5
|
+
*
|
|
6
|
+
* - `requirePrecondition(client, reqs)`: sync guard, throws
|
|
7
|
+
* `PreconditionNotMetError` on first failing field.
|
|
8
|
+
* - `itOnDevice` / `describeOnDevice`: registration-time wrappers
|
|
9
|
+
* that read `DeviceInfo` synchronously from `device-env.json`
|
|
10
|
+
* and call `.skip()` on mismatch.
|
|
11
|
+
*
|
|
12
|
+
* Evaluation order is the caller's `reqs` key insertion order
|
|
13
|
+
* (JS preserves it), so the caller picks which field reports first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import semver from "semver";
|
|
17
|
+
|
|
18
|
+
import type { RkClient } from "../client/client";
|
|
19
|
+
import { getDeviceClass } from "../device/device-class";
|
|
20
|
+
import { readCachedDeviceInfoSync } from "../device/device-env-sync";
|
|
21
|
+
import { PreconditionNotImplementedError, PreconditionNotMetError, RkClientError } from "../errors";
|
|
22
|
+
import type { DeviceInfo, Precondition } from "../types";
|
|
23
|
+
|
|
24
|
+
export interface OnDeviceTestOptions {
|
|
25
|
+
preconditions?: Precondition;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OnDeviceDescribeOptions {
|
|
30
|
+
preconditions?: Precondition;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sync precondition guard. Throws `PreconditionNotMetError` on
|
|
35
|
+
* mismatch. A throw here is a Jest **failure**, not a skip
|
|
36
|
+
* (jest-circus has no mid-test skip); use `itOnDevice` /
|
|
37
|
+
* `describeOnDevice` for skip-on-mismatch semantics.
|
|
38
|
+
*/
|
|
39
|
+
export function requirePrecondition(client: RkClient, requirements: Precondition): void {
|
|
40
|
+
const info = client.cachedDeviceInfo();
|
|
41
|
+
if (info === null) {
|
|
42
|
+
throw new RkClientError(
|
|
43
|
+
`requirePrecondition: no cached DeviceInfo available. Call installDeviceTestHooks or await client.queryDeviceInfo() before requirePrecondition so the cache is populated.`,
|
|
44
|
+
"PRECONDITION_NO_DEVICE_INFO"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
evaluatePrecondition(info, requirements);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Evaluate `reqs` against `info`. Throws `PreconditionNotMetError`
|
|
52
|
+
* (or `PreconditionNotImplementedError`) on first failing field.
|
|
53
|
+
* Internal helper for `requirePrecondition` and the registration
|
|
54
|
+
* wrappers.
|
|
55
|
+
*/
|
|
56
|
+
export function evaluatePrecondition(info: DeviceInfo, reqs: Precondition): void {
|
|
57
|
+
for (const key of Object.keys(reqs) as Array<keyof Precondition>) {
|
|
58
|
+
switch (key) {
|
|
59
|
+
case "firmwareVersion": {
|
|
60
|
+
const required = reqs.firmwareVersion;
|
|
61
|
+
if (required === undefined) continue;
|
|
62
|
+
checkSemver("firmwareVersion", info.firmwareVersion, required);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
case "modelFamily":
|
|
66
|
+
if (reqs.modelFamily !== undefined) {
|
|
67
|
+
checkExact("modelFamily", info.modelFamily, reqs.modelFamily);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
case "deviceClass":
|
|
71
|
+
if (reqs.deviceClass !== undefined) {
|
|
72
|
+
// DeviceInfo.deviceClass is a form-factor tag;
|
|
73
|
+
// perf tier comes from the static modelName table.
|
|
74
|
+
const derived = getDeviceClass(info.modelName);
|
|
75
|
+
checkExact("deviceClass", derived, reqs.deviceClass);
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
case "videoMode":
|
|
79
|
+
if (reqs.videoMode !== undefined) {
|
|
80
|
+
checkVideoMode(info.videoMode, reqs.videoMode);
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
case "locale":
|
|
84
|
+
if (reqs.locale !== undefined) {
|
|
85
|
+
checkExact("locale", info.locale, reqs.locale);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
case "country":
|
|
89
|
+
if (reqs.country !== undefined) {
|
|
90
|
+
checkExact("country", info.country, reqs.country);
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
case "library": {
|
|
94
|
+
if (reqs.library === undefined) continue;
|
|
95
|
+
// ECP doesn't surface library versions reliably;
|
|
96
|
+
// dedicated subclass keeps reporters distinguishable.
|
|
97
|
+
const expected = `${reqs.library.name}@${reqs.library.version}`;
|
|
98
|
+
throw new PreconditionNotImplementedError(
|
|
99
|
+
`Precondition not met: library version checks (${expected}) are not implemented — ECP does not surface library versions today.`,
|
|
100
|
+
{
|
|
101
|
+
feature: "library-version",
|
|
102
|
+
field: "library",
|
|
103
|
+
expected,
|
|
104
|
+
actual: info.libraries?.[reqs.library.name] ?? ""
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------
|
|
113
|
+
// Field checkers
|
|
114
|
+
// ---------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function checkExact(field: string, actual: string, expected: string): void {
|
|
117
|
+
if (actual === expected) return;
|
|
118
|
+
throw new PreconditionNotMetError(
|
|
119
|
+
`Precondition not met: requires ${field}="${expected}", device has ${field}="${actual}"`,
|
|
120
|
+
{ field, expected, actual, reason: "mismatch" }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Prefix-match: `"1080p"` matches `"1080p"` and `"1080p60"`.
|
|
126
|
+
* Callers wanting exact match declare the longer prefix.
|
|
127
|
+
*/
|
|
128
|
+
function checkVideoMode(actual: string, expected: string): void {
|
|
129
|
+
if (actual.startsWith(expected)) return;
|
|
130
|
+
throw new PreconditionNotMetError(
|
|
131
|
+
`Precondition not met: requires videoMode="${expected}", device has videoMode="${actual}"`,
|
|
132
|
+
{ field: "videoMode", expected, actual, reason: "mismatch" }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Coerces 4-part Roku firmware (`"12.5.4.1234"` → `"12.5.4"`) and
|
|
138
|
+
* runs through `semver.satisfies`. Invalid range → `RkClientError`
|
|
139
|
+
* (test-author bug). Non-semver `actual` → fail closed as mismatch.
|
|
140
|
+
*/
|
|
141
|
+
function checkSemver(field: string, actual: string, range: string): void {
|
|
142
|
+
const coercedActual = semver.coerce(actual);
|
|
143
|
+
const validRange = semver.validRange(range);
|
|
144
|
+
if (validRange === null) {
|
|
145
|
+
throw new RkClientError(
|
|
146
|
+
`Invalid semver range in precondition: ${field}="${range}". Examples of valid ranges: ">=12.0", ">=12.0 <14.0", "^12.0".`,
|
|
147
|
+
"PRECONDITION_INVALID_SEMVER_RANGE"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (coercedActual === null) {
|
|
151
|
+
throw new PreconditionNotMetError(
|
|
152
|
+
`Precondition not met: requires ${field}${range}, device has ${field}=${actual || "<empty>"}`,
|
|
153
|
+
{ field, expected: range, actual, reason: "mismatch" }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (semver.satisfies(coercedActual, validRange)) return;
|
|
157
|
+
throw new PreconditionNotMetError(
|
|
158
|
+
`Precondition not met: requires ${field}${range}, device has ${field}=${actual}`,
|
|
159
|
+
{ field, expected: range, actual, reason: "mismatch" }
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------
|
|
164
|
+
// Registration-time wrappers
|
|
165
|
+
// ---------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
// Per-module flag: warn once per file, not once per itOnDevice call.
|
|
168
|
+
let fallbackWarned = false;
|
|
169
|
+
|
|
170
|
+
/** Test-only reset for `fallbackWarned`. NOT exported via index.ts. */
|
|
171
|
+
export function __resetFallbackWarnedForTests(): void {
|
|
172
|
+
fallbackWarned = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Returns the mismatch error, or null on pass / missing snapshot.
|
|
177
|
+
* Missing snapshot → register normally; the first real ECP call
|
|
178
|
+
* fails fast with remediation if no device is connected.
|
|
179
|
+
*/
|
|
180
|
+
function evaluateAtRegistration(preconditions: Precondition | undefined): PreconditionNotMetError | null {
|
|
181
|
+
if (!preconditions || Object.keys(preconditions).length === 0) return null;
|
|
182
|
+
const info = readCachedDeviceInfoSync();
|
|
183
|
+
if (info === null) {
|
|
184
|
+
if (!fallbackWarned) {
|
|
185
|
+
fallbackWarned = true;
|
|
186
|
+
console.warn(
|
|
187
|
+
`[device-test-client] No device-env.json snapshot found at ${process.cwd()}; ` +
|
|
188
|
+
`itOnDevice/describeOnDevice preconditions will not be enforced for this run. ` +
|
|
189
|
+
`Run via Jest globalSetup (installDeviceTestHooks wiring) so the snapshot is written.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
evaluatePrecondition(info, preconditions);
|
|
196
|
+
return null;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof PreconditionNotMetError) return err;
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Strip the stable `"Precondition not met: "` prefix from the skip
|
|
205
|
+
* label so Jest's reporter one-liner stays compact.
|
|
206
|
+
*/
|
|
207
|
+
function skipLabel(name: string, err: PreconditionNotMetError): string {
|
|
208
|
+
const prefix = "Precondition not met: ";
|
|
209
|
+
const reason = err.message.startsWith(prefix) ? err.message.slice(prefix.length) : err.message;
|
|
210
|
+
return `${name} (skipped: ${reason})`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Registration-time `it` wrapper that calls `it.skip()` on a
|
|
215
|
+
* synchronously-evaluated precondition mismatch. No `preconditions`
|
|
216
|
+
* → plain `it()`. No `device-env.json` → register normally + warn.
|
|
217
|
+
*/
|
|
218
|
+
export function itOnDevice(name: string, fn: () => void | Promise<void>, opts: OnDeviceTestOptions = {}): void {
|
|
219
|
+
const timeout = opts.timeout;
|
|
220
|
+
// ProvidesCallback union prefers Promise<unknown>; our signature
|
|
221
|
+
// is a strict subset at runtime.
|
|
222
|
+
const cb = fn as jest.ProvidesCallback;
|
|
223
|
+
const err = evaluateAtRegistration(opts.preconditions);
|
|
224
|
+
if (err) {
|
|
225
|
+
it.skip(skipLabel(name, err), cb, timeout);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
it(name, cb, timeout);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Registration-time `describe` wrapper; see `itOnDevice`. */
|
|
232
|
+
export function describeOnDevice(name: string, fn: () => void, opts: OnDeviceDescribeOptions = {}): void {
|
|
233
|
+
const err = evaluateAtRegistration(opts.preconditions);
|
|
234
|
+
if (err) {
|
|
235
|
+
describe.skip(skipLabel(name, err), fn);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
describe(name, fn);
|
|
239
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Device-lease telemetry helpers for the parallel-device rollout.
|
|
5
|
+
*
|
|
6
|
+
* Two outputs, both consumed downstream by post-run analysis:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`[device-lease]` structured log line.** Single-line
|
|
9
|
+
* `<prefix> <json>` written by `globalSetup` (acquire) and
|
|
10
|
+
* `globalTeardown` (release). The single-line shape is
|
|
11
|
+
* load-bearing: log aggregators parse with a one-shot regex
|
|
12
|
+
* (`^\[device-lease\] (\{.*\})$`), no backreferences, no
|
|
13
|
+
* multi-line accumulators. Insertion order of the JSON object
|
|
14
|
+
* keys is `device, shard, provider, pid, host, acquiredAt|releasedAt`
|
|
15
|
+
* — relied on by the dashboard's column ordering.
|
|
16
|
+
*
|
|
17
|
+
* 2. **JUnit `<properties>` per `<testcase>`.** `jest-junit@16`
|
|
18
|
+
* does NOT support a static `properties` config option. The
|
|
19
|
+
* supported mechanism is a CommonJS file
|
|
20
|
+
* pointed at by the reporter's `testCasePropertiesFile` /
|
|
21
|
+
* `testCasePropertiesDirectory` options; that file is
|
|
22
|
+
* `require()`d at JUnit-generation time and must export a
|
|
23
|
+
* function `(testCase) => Record<string, string>`. We bake the
|
|
24
|
+
* resolved values into the file source at `globalSetup` time
|
|
25
|
+
* so the reporter (which runs in the parent Jest process at
|
|
26
|
+
* the very end of the run) does not need to re-read the
|
|
27
|
+
* environment — env mutations by globalSetup don't always
|
|
28
|
+
* survive across the lifecycle on every Jest config.
|
|
29
|
+
*
|
|
30
|
+
* Both consumers — log line and JUnit property — read the same
|
|
31
|
+
* resolved triple `{ deviceId, shardName, providerKind }` so a
|
|
32
|
+
* single source of truth keeps them from drifting.
|
|
33
|
+
*
|
|
34
|
+
* Leaf module: must not import from `client/`, `assertions/`, or
|
|
35
|
+
* any other `jest/` file. Pure functions only; the only side
|
|
36
|
+
* effects are `process.stdout` (via `console.log`) and the atomic
|
|
37
|
+
* file write in `writeJUnitTelemetryPropertiesFile`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { hostname } from "node:os";
|
|
42
|
+
import { dirname } from "node:path";
|
|
43
|
+
|
|
44
|
+
import { ENV_VAR } from "../constants";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default `providerKind` used when `RK_PROVIDER_KIND` is unset.
|
|
48
|
+
* The baseline only knows the local provider; the provider seam
|
|
49
|
+
* and the internal / custom providers set the env var explicitly via the CLI.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_PROVIDER_KIND = "local";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default `shardName` used when `RK_SHARD_NAME` is unset.
|
|
55
|
+
* Literal `"default"` (not empty string) so JUnit consumers and
|
|
56
|
+
* dashboards never render an empty value — most viewers display
|
|
57
|
+
* empty as "(empty)" or hide the field entirely, which makes
|
|
58
|
+
* un-sharded baseline runs invisible in the rollout report.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_SHARD_NAME = "default";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Prefix on every `[device-lease]` log line. Unique enough that
|
|
64
|
+
* a `grep "^\[device-lease\] "` against the CI log gives a clean
|
|
65
|
+
* acquire/release timeline. Kept distinct from the
|
|
66
|
+
* `[device-test-client]` prefix used by the existing diagnostics
|
|
67
|
+
* so log aggregators can route the two streams independently.
|
|
68
|
+
*/
|
|
69
|
+
export const DEVICE_LEASE_LOG_PREFIX = "[device-lease]";
|
|
70
|
+
|
|
71
|
+
/** Resolved telemetry inputs shared by the log line and the JUnit properties. */
|
|
72
|
+
export interface DeviceLeaseTelemetry {
|
|
73
|
+
/**
|
|
74
|
+
* Stable identifier for the physical (or virtual) device.
|
|
75
|
+
* For `LocalProvider`: the resolved device IP. For internal / custom:
|
|
76
|
+
* the inventory id / cloud slot id.
|
|
77
|
+
*/
|
|
78
|
+
readonly deviceId: string;
|
|
79
|
+
/** GitLab CI shard / job slice (`RK_SHARD_NAME`); `"default"` if unset. */
|
|
80
|
+
readonly shardName: string;
|
|
81
|
+
/** Device-pool provider kind (`RK_PROVIDER_KIND`); `"local"` if unset. */
|
|
82
|
+
readonly providerKind: string;
|
|
83
|
+
/** OS pid of the Jest parent process emitting the log. */
|
|
84
|
+
readonly pid: number;
|
|
85
|
+
/** `os.hostname()` of the runner / pod emitting the log. */
|
|
86
|
+
readonly host: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `[device-lease]` log payload emitted on `globalSetup`. The
|
|
91
|
+
* `acquiredAt` field is the ISO-8601 timestamp when the suite
|
|
92
|
+
* took ownership of the device. Kept readonly so consumers can
|
|
93
|
+
* `JSON.stringify` without worrying about accidental mutation
|
|
94
|
+
* after capture.
|
|
95
|
+
*/
|
|
96
|
+
export interface DeviceLeaseAcquireEvent extends DeviceLeaseTelemetry {
|
|
97
|
+
/** ISO-8601 (with `Z`); generated via `new Date().toISOString()`. */
|
|
98
|
+
readonly acquiredAt: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `[device-lease]` log payload emitted on `globalTeardown`. */
|
|
102
|
+
export interface DeviceLeaseReleaseEvent extends DeviceLeaseTelemetry {
|
|
103
|
+
/** ISO-8601 (with `Z`); generated via `new Date().toISOString()`. */
|
|
104
|
+
readonly releasedAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type DeviceLeaseEvent = DeviceLeaseAcquireEvent | DeviceLeaseReleaseEvent;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Shape of the JUnit `<property>` set written per `<testcase>`.
|
|
111
|
+
*
|
|
112
|
+
* Field names mirror the spec verbatim: `device-id` and
|
|
113
|
+
* `shard-name` are kebab-case, `providerKind` is camelCase.
|
|
114
|
+
* The mixed convention is intentional at this layer — the
|
|
115
|
+
* downstream analysis scripts that consume these properties key
|
|
116
|
+
* off these exact strings, so renaming requires a coordinated
|
|
117
|
+
* change in both producers and consumers.
|
|
118
|
+
*/
|
|
119
|
+
export interface JUnitTelemetryProperties {
|
|
120
|
+
readonly "device-id": string;
|
|
121
|
+
readonly "shard-name": string;
|
|
122
|
+
readonly providerKind: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve telemetry inputs from a known `deviceId` plus the
|
|
127
|
+
* environment / process. The argument struct is a discriminated
|
|
128
|
+
* input so partner consumers can construct their own resolver
|
|
129
|
+
* without re-reading `process.env` (e.g. when wrapping in a
|
|
130
|
+
* different host like Bun or a non-Jest harness).
|
|
131
|
+
*
|
|
132
|
+
* Pure: no I/O, no `console.*`. Safe to call from anywhere.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveTelemetry(args: {
|
|
135
|
+
/** Resolved device identifier (typically the IP for `LocalProvider`). */
|
|
136
|
+
deviceId: string;
|
|
137
|
+
/** Defaults to `process.env`. */
|
|
138
|
+
env?: NodeJS.ProcessEnv;
|
|
139
|
+
/** Defaults to `process.pid`. */
|
|
140
|
+
pid?: number;
|
|
141
|
+
/** Defaults to `os.hostname()`. */
|
|
142
|
+
host?: string;
|
|
143
|
+
}): DeviceLeaseTelemetry {
|
|
144
|
+
// Fail-fast partner-API guard: an empty deviceId would emit
|
|
145
|
+
// `"device":""` to the log line and the JUnit property —
|
|
146
|
+
// indistinguishable from "field missing" in most viewers and
|
|
147
|
+
// a silent breakage of the telemetry contract. The
|
|
148
|
+
// first-party caller (`global-setup.ts`) already gets a
|
|
149
|
+
// non-empty IP from `resolveActiveDeviceIp` (which throws on
|
|
150
|
+
// empty), so this only fires for partner misuse.
|
|
151
|
+
if (args.deviceId.length === 0) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"[device-lease] resolveTelemetry: deviceId must be a non-empty string. " +
|
|
154
|
+
"If you are wiring telemetry into a custom globalSetup, resolve the device " +
|
|
155
|
+
"identifier (IP for LocalProvider, slot id for cloud providers) before calling."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const env = args.env ?? process.env;
|
|
159
|
+
const rawShard = env[ENV_VAR.RK_SHARD_NAME];
|
|
160
|
+
const rawProvider = env[ENV_VAR.RK_PROVIDER_KIND];
|
|
161
|
+
return {
|
|
162
|
+
deviceId: args.deviceId,
|
|
163
|
+
shardName: rawShard && rawShard.length > 0 ? rawShard : DEFAULT_SHARD_NAME,
|
|
164
|
+
providerKind: rawProvider && rawProvider.length > 0 ? rawProvider : DEFAULT_PROVIDER_KIND,
|
|
165
|
+
pid: args.pid ?? process.pid,
|
|
166
|
+
host: args.host ?? hostname()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the acquire-event payload. Separated from
|
|
172
|
+
* {@link emitDeviceLeaseLog} so callers can capture-and-assert
|
|
173
|
+
* (unit tests) without piping `console.log` through a stub, and
|
|
174
|
+
* so the same payload can be persisted alongside the log line
|
|
175
|
+
* in a later phase if needed.
|
|
176
|
+
*/
|
|
177
|
+
export function buildAcquireEvent(
|
|
178
|
+
telemetry: DeviceLeaseTelemetry,
|
|
179
|
+
/** Defaults to `new Date().toISOString()`. */
|
|
180
|
+
acquiredAt: string = new Date().toISOString()
|
|
181
|
+
): DeviceLeaseAcquireEvent {
|
|
182
|
+
return { ...telemetry, acquiredAt };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the release-event payload. Symmetric to
|
|
187
|
+
* {@link buildAcquireEvent} — see that function's docstring for
|
|
188
|
+
* the rationale of the explicit-timestamp parameter.
|
|
189
|
+
*/
|
|
190
|
+
export function buildReleaseEvent(
|
|
191
|
+
telemetry: DeviceLeaseTelemetry,
|
|
192
|
+
/** Defaults to `new Date().toISOString()`. */
|
|
193
|
+
releasedAt: string = new Date().toISOString()
|
|
194
|
+
): DeviceLeaseReleaseEvent {
|
|
195
|
+
return { ...telemetry, releasedAt };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Format a `[device-lease]` log line. Output shape:
|
|
200
|
+
*
|
|
201
|
+
* [device-lease] {"device":"<id>","shard":"<shard>","provider":"<kind>","pid":<n>,"host":"<host>","acquiredAt":"<iso>"}
|
|
202
|
+
* [device-lease] {"device":"<id>","shard":"<shard>","provider":"<kind>","pid":<n>,"host":"<host>","releasedAt":"<iso>"}
|
|
203
|
+
*
|
|
204
|
+
* Insertion order of the keys matters: the dashboard pivots on
|
|
205
|
+
* column order to render an "active leases at time T" timeline,
|
|
206
|
+
* and `JSON.stringify` preserves insertion order for non-numeric
|
|
207
|
+
* string keys (ECMA-262 §9.1.12 / `OrdinaryOwnPropertyKeys`).
|
|
208
|
+
* Always exactly one space between prefix and JSON; no trailing
|
|
209
|
+
* whitespace; no embedded newlines.
|
|
210
|
+
*/
|
|
211
|
+
export function formatDeviceLeaseLog(event: DeviceLeaseEvent): string {
|
|
212
|
+
// Discriminant guard: the union is acquire XOR release. A
|
|
213
|
+
// partner that hand-constructs an event with both fields
|
|
214
|
+
// (e.g. via `{ ...acquireEvent, releasedAt: "..." }`) would
|
|
215
|
+
// silently get the acquire branch under naive `"x" in event`
|
|
216
|
+
// checks and ship a corrupt record. Fail fast so the contract
|
|
217
|
+
// violation surfaces at the call site, not on the dashboard.
|
|
218
|
+
const hasAcquire = "acquiredAt" in event;
|
|
219
|
+
const hasRelease = "releasedAt" in event;
|
|
220
|
+
if (hasAcquire === hasRelease) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"[device-lease] formatDeviceLeaseLog: event must have exactly one of " +
|
|
223
|
+
"`acquiredAt` or `releasedAt` (got " +
|
|
224
|
+
`acquiredAt=${hasAcquire}, releasedAt=${hasRelease}). ` +
|
|
225
|
+
"Use buildAcquireEvent/buildReleaseEvent to construct events safely."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// Build the wire-format object with the spec's key names
|
|
229
|
+
// (`device`, `shard`, `provider` — NOT the JUnit property
|
|
230
|
+
// names). Insertion order = wire order.
|
|
231
|
+
const wire: Record<string, string | number> = {
|
|
232
|
+
device: event.deviceId,
|
|
233
|
+
shard: event.shardName,
|
|
234
|
+
provider: event.providerKind,
|
|
235
|
+
pid: event.pid,
|
|
236
|
+
host: event.host
|
|
237
|
+
};
|
|
238
|
+
if (hasAcquire) {
|
|
239
|
+
wire.acquiredAt = (event as DeviceLeaseAcquireEvent).acquiredAt;
|
|
240
|
+
} else {
|
|
241
|
+
wire.releasedAt = (event as DeviceLeaseReleaseEvent).releasedAt;
|
|
242
|
+
}
|
|
243
|
+
return `${DEVICE_LEASE_LOG_PREFIX} ${JSON.stringify(wire)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Emit a `[device-lease]` log line to stdout. `console.log`
|
|
248
|
+
* (not `process.stdout.write`) for two reasons:
|
|
249
|
+
*
|
|
250
|
+
* - It already trailing-newlines, matching every other log
|
|
251
|
+
* line emitted by `device-test-client` (`logDeviceDiagnostic`,
|
|
252
|
+
* the `console.warn` paths in `global-setup.ts`).
|
|
253
|
+
* - Jest does NOT swallow `globalSetup` / `globalTeardown`
|
|
254
|
+
* `console.*` output (those run in the parent process,
|
|
255
|
+
* outside the per-test console buffering); CI sees it
|
|
256
|
+
* verbatim regardless of Jest verbosity.
|
|
257
|
+
*/
|
|
258
|
+
export function emitDeviceLeaseLog(event: DeviceLeaseEvent): void {
|
|
259
|
+
console.log(formatDeviceLeaseLog(event));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build the per-`<testcase>` JUnit properties record. Pure
|
|
264
|
+
* helper exposed independently of the file writer so partners
|
|
265
|
+
* who roll their own `jest-junit` plumbing (or use a different
|
|
266
|
+
* JUnit reporter) can plug the same data into their own
|
|
267
|
+
* mechanism.
|
|
268
|
+
*/
|
|
269
|
+
export function buildJUnitTelemetryProperties(telemetry: DeviceLeaseTelemetry): JUnitTelemetryProperties {
|
|
270
|
+
return {
|
|
271
|
+
"device-id": telemetry.deviceId,
|
|
272
|
+
"shard-name": telemetry.shardName,
|
|
273
|
+
providerKind: telemetry.providerKind
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Atomically write the CommonJS file that `jest-junit`
|
|
279
|
+
* `require()`s at JUnit-generation time. Contract with the
|
|
280
|
+
* reporter (`buildJsonResults.js`):
|
|
281
|
+
*
|
|
282
|
+
* module.exports = function (testCase) { return {...}; };
|
|
283
|
+
*
|
|
284
|
+
* The function returns a constant for every test case because
|
|
285
|
+
* device-id / shard-name / provider-kind are run-scoped, not
|
|
286
|
+
* test-scoped. We bake JSON-encoded values into the file source
|
|
287
|
+
* (rather than reading env vars at require-time) so the reporter
|
|
288
|
+
* is decoupled from the runtime env of the Jest parent — env
|
|
289
|
+
* mutations by globalSetup are not guaranteed to survive across
|
|
290
|
+
* every Jest configuration (workers, custom test environments).
|
|
291
|
+
*
|
|
292
|
+
* Atomic write (`write tmp` + `renameSync`) prevents jest-junit
|
|
293
|
+
* from `require()`-ing a torn partial file if the run is killed
|
|
294
|
+
* mid-write; mirrors the precedent in
|
|
295
|
+
* [`artifacts.ts → writeDeviceEnvSnapshot`](./artifacts.ts).
|
|
296
|
+
*/
|
|
297
|
+
export function writeJUnitTelemetryPropertiesFile(filePath: string, telemetry: DeviceLeaseTelemetry): void {
|
|
298
|
+
const props = buildJUnitTelemetryProperties(telemetry);
|
|
299
|
+
// `JSON.stringify` for each value gives us a safe encoding
|
|
300
|
+
// for any character (quotes, backslashes, newlines), and a
|
|
301
|
+
// valid JS string literal — the file body is then a tiny
|
|
302
|
+
// CJS module returning that literal-keyed object on every
|
|
303
|
+
// call. Generated lines are kept short for human readability
|
|
304
|
+
// when a CI engineer cats the file during triage.
|
|
305
|
+
const body =
|
|
306
|
+
"// AUTO-GENERATED by @roku-sdk/device-test-client/jest/telemetry.ts.\n" +
|
|
307
|
+
"// Read by `jest-junit` to populate per-<testcase> <properties>.\n" +
|
|
308
|
+
"// Do not edit by hand and do not commit — this file is rewritten\n" +
|
|
309
|
+
"// every run by `globalSetup` and lives under `test-results/` which\n" +
|
|
310
|
+
"// is git-ignored except for `.gitkeep`.\n" +
|
|
311
|
+
"module.exports = function deviceLeaseTestCaseProperties() {\n" +
|
|
312
|
+
" return {\n" +
|
|
313
|
+
` ${JSON.stringify("device-id")}: ${JSON.stringify(props["device-id"])},\n` +
|
|
314
|
+
` ${JSON.stringify("shard-name")}: ${JSON.stringify(props["shard-name"])},\n` +
|
|
315
|
+
` ${JSON.stringify("providerKind")}: ${JSON.stringify(props.providerKind)}\n` +
|
|
316
|
+
" };\n" +
|
|
317
|
+
"};\n";
|
|
318
|
+
// Ensure the parent directory exists. The first-party caller
|
|
319
|
+
// (`global-setup.ts`) goes through `cleanArtifacts` which
|
|
320
|
+
// creates `test-results/` recursively, but a partner using
|
|
321
|
+
// this helper standalone may point it at an unmade path.
|
|
322
|
+
// `mkdir -p` semantics — no-op if the dir exists.
|
|
323
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
324
|
+
// Per-pid+timestamp tmp name so concurrent writes (e.g. a
|
|
325
|
+
// partner that runs two suites against two devices in
|
|
326
|
+
// separate test-results dirs sharing a parent) cannot
|
|
327
|
+
// clobber each other's in-flight tmp files.
|
|
328
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
329
|
+
writeFileSync(tmp, body, "utf8");
|
|
330
|
+
renameSync(tmp, filePath);
|
|
331
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Streams every observed `LogBuffer` line to
|
|
5
|
+
* `test-results/device.log`. Single-writer: relies on
|
|
6
|
+
* `maxWorkers: 1`. If that pin is ever lifted, consumers must
|
|
7
|
+
* shard per worker (e.g. `device.<worker>.log`).
|
|
8
|
+
*
|
|
9
|
+
* Leaf module: structural `LogBufferLike` keeps the graph acyclic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createWriteStream, type WriteStream } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { ARTIFACT_FILE } from "../constants";
|
|
16
|
+
import type { LogBufferLike } from "../types";
|
|
17
|
+
|
|
18
|
+
export interface DeviceLogSink {
|
|
19
|
+
/** Append `line\n`. Never throws; errors surface on close(). */
|
|
20
|
+
write(line: string): void;
|
|
21
|
+
/** Flush and close. Subsequent writes are no-ops. */
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Truncate-open `<testResultsDir>/device.log`. Emits a header with
|
|
27
|
+
* device IP + ISO timestamp.
|
|
28
|
+
*/
|
|
29
|
+
export function openDeviceLogSink(testResultsDir: string, deviceIp?: string): DeviceLogSink {
|
|
30
|
+
const file = join(testResultsDir, ARTIFACT_FILE.DEVICE_LOG);
|
|
31
|
+
const stream: WriteStream = createWriteStream(file, {
|
|
32
|
+
flags: "w",
|
|
33
|
+
encoding: "utf8"
|
|
34
|
+
});
|
|
35
|
+
const header = `# device.log — run started ${new Date().toISOString()} — ` + `device ${deviceIp ?? "unknown"}\n`;
|
|
36
|
+
stream.write(header);
|
|
37
|
+
|
|
38
|
+
let closed = false;
|
|
39
|
+
return {
|
|
40
|
+
write(line: string): void {
|
|
41
|
+
if (closed) return;
|
|
42
|
+
// Swallow write errors so a destroyed stream can't
|
|
43
|
+
// unwind through `LogBuffer.append` and kill the test.
|
|
44
|
+
try {
|
|
45
|
+
stream.write(`${line}\n`);
|
|
46
|
+
} catch {
|
|
47
|
+
// best-effort; close() will surface any latent error
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
close(): Promise<void> {
|
|
51
|
+
if (closed) return Promise.resolve();
|
|
52
|
+
closed = true;
|
|
53
|
+
return new Promise<void>(resolve => {
|
|
54
|
+
stream.end(() => resolve());
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Subscribe `sink` to `buffer`'s append feed. Caller MUST invoke the
|
|
62
|
+
* returned unsubscribe BEFORE `sink.close()` to avoid writing to an
|
|
63
|
+
* already-ended stream.
|
|
64
|
+
*/
|
|
65
|
+
export function attachToBuffer(sink: DeviceLogSink, buffer: LogBufferLike): () => void {
|
|
66
|
+
return buffer.subscribe(entry => {
|
|
67
|
+
sink.write(entry.line);
|
|
68
|
+
});
|
|
69
|
+
}
|