@roku-sdk/device-test-client 0.0.0-stage → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/docs/baselines.md +221 -0
  4. package/docs/configuration.md +258 -0
  5. package/docs/dom-assertions.md +264 -0
  6. package/docs/errors.md +525 -0
  7. package/docs/examples/deep-link-cold-start.md +85 -0
  8. package/docs/examples/focus-navigation.md +81 -0
  9. package/docs/examples/log-driven-assertion.md +99 -0
  10. package/docs/examples/precondition-skips.md +150 -0
  11. package/docs/examples/screenshot-regression.md +103 -0
  12. package/docs/examples/smoke-test.md +69 -0
  13. package/docs/getting-started.md +195 -0
  14. package/docs/jest-integration.md +331 -0
  15. package/docs/log-assertions.md +314 -0
  16. package/docs/preconditions.md +317 -0
  17. package/docs/rkclient.md +655 -0
  18. package/docs/screenshot-assertions.md +316 -0
  19. package/internal/constants.ts +54 -0
  20. package/internal/deploy.ts +60 -0
  21. package/internal/paths.ts +15 -0
  22. package/package.json +80 -3
  23. package/src/assertions/assertions.ts +661 -0
  24. package/src/assertions/baseline-env-guard.ts +43 -0
  25. package/src/assertions/baselines.ts +178 -0
  26. package/src/assertions/screenshot-artifacts.ts +39 -0
  27. package/src/assertions/screenshot-diff.ts +447 -0
  28. package/src/client/build-cache.ts +292 -0
  29. package/src/client/client.ts +1656 -0
  30. package/src/client/deploy.ts +402 -0
  31. package/src/client/ecp-queue.ts +112 -0
  32. package/src/client/ecp-retry.ts +94 -0
  33. package/src/client/factory.ts +147 -0
  34. package/src/client/health.ts +214 -0
  35. package/src/client/parsers.ts +155 -0
  36. package/src/client/server-url.ts +137 -0
  37. package/src/constants.ts +471 -0
  38. package/src/device/device-class.ts +57 -0
  39. package/src/device/device-env-sync.ts +58 -0
  40. package/src/device/device-resolver-sync.ts +43 -0
  41. package/src/device/device-resolver.ts +141 -0
  42. package/src/device/lease-provisioning.ts +54 -0
  43. package/src/errors.ts +773 -0
  44. package/src/index.ts +186 -0
  45. package/src/internal/constants.ts +3 -0
  46. package/src/jest/artifacts.ts +87 -0
  47. package/src/jest/device-test-environment.ts +420 -0
  48. package/src/jest/failure-artifacts.ts +169 -0
  49. package/src/jest/global-setup.ts +133 -0
  50. package/src/jest/global-teardown.ts +132 -0
  51. package/src/jest/install-hooks.ts +488 -0
  52. package/src/jest/jest-setup.ts +53 -0
  53. package/src/jest/preconditions.ts +239 -0
  54. package/src/jest/telemetry.ts +331 -0
  55. package/src/logs/device-log-sink.ts +69 -0
  56. package/src/logs/log-buffer.ts +265 -0
  57. package/src/logs/log-session.ts +374 -0
  58. package/src/logs/log-stream-client.ts +296 -0
  59. package/src/paths.ts +65 -0
  60. package/src/types.ts +443 -0
@@ -0,0 +1,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
+ }