@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.
Files changed (61) hide show
  1. package/BREAKING_CHANGES.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -2
  4. package/docs/baselines.md +221 -0
  5. package/docs/configuration.md +258 -0
  6. package/docs/dom-assertions.md +264 -0
  7. package/docs/errors.md +544 -0
  8. package/docs/examples/deep-link-cold-start.md +85 -0
  9. package/docs/examples/focus-navigation.md +81 -0
  10. package/docs/examples/log-driven-assertion.md +99 -0
  11. package/docs/examples/precondition-skips.md +150 -0
  12. package/docs/examples/screenshot-regression.md +103 -0
  13. package/docs/examples/smoke-test.md +69 -0
  14. package/docs/getting-started.md +195 -0
  15. package/docs/jest-integration.md +331 -0
  16. package/docs/log-assertions.md +349 -0
  17. package/docs/preconditions.md +317 -0
  18. package/docs/rkclient.md +657 -0
  19. package/docs/screenshot-assertions.md +316 -0
  20. package/package.json +81 -3
  21. package/src/assertions/assertions.ts +661 -0
  22. package/src/assertions/baseline-env-guard.ts +43 -0
  23. package/src/assertions/baselines.ts +178 -0
  24. package/src/assertions/screenshot-artifacts.ts +39 -0
  25. package/src/assertions/screenshot-diff.ts +447 -0
  26. package/src/client/build-cache.ts +292 -0
  27. package/src/client/client.ts +1964 -0
  28. package/src/client/deploy.ts +407 -0
  29. package/src/client/ecp-queue.ts +112 -0
  30. package/src/client/ecp-retry.ts +94 -0
  31. package/src/client/factory.ts +170 -0
  32. package/src/client/health.ts +345 -0
  33. package/src/client/parsers.ts +162 -0
  34. package/src/client/server-url.ts +137 -0
  35. package/src/constants.ts +520 -0
  36. package/src/device/device-class.ts +57 -0
  37. package/src/device/device-env-sync.ts +58 -0
  38. package/src/device/device-resolver-sync.ts +43 -0
  39. package/src/device/device-resolver.ts +141 -0
  40. package/src/device/lease-provisioning.ts +54 -0
  41. package/src/errors.ts +774 -0
  42. package/src/index.ts +181 -0
  43. package/src/internal/constants.ts +3 -0
  44. package/src/jest/artifacts.ts +87 -0
  45. package/src/jest/device-test-environment.ts +420 -0
  46. package/src/jest/failure-artifacts.ts +169 -0
  47. package/src/jest/global-setup.ts +137 -0
  48. package/src/jest/global-teardown.ts +132 -0
  49. package/src/jest/install-hooks.ts +490 -0
  50. package/src/jest/jest-setup.ts +53 -0
  51. package/src/jest/preconditions.ts +239 -0
  52. package/src/jest/telemetry.ts +331 -0
  53. package/src/logs/device-log-sink.ts +69 -0
  54. package/src/logs/log-buffer.ts +278 -0
  55. package/src/logs/log-session.ts +436 -0
  56. package/src/logs/log-stream-client.ts +296 -0
  57. package/src/paths.ts +65 -0
  58. package/src/types.ts +472 -0
  59. package/support/constants.ts +17 -0
  60. package/support/deploy.ts +27 -0
  61. package/support/paths.ts +13 -0
@@ -0,0 +1,57 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `modelName` → coarse performance tier for precondition gating.
5
+ * Separate from `DeviceInfo.deviceClass` (which is a form-factor
6
+ * classifier). Hand-maintained because the model→tier mapping is
7
+ * not algorithmic. Unknown models → `"unknown"` so preconditions
8
+ * fail closed.
9
+ */
10
+
11
+ export type DeviceClass = "high-end" | "mid-range" | "low-end" | "unknown";
12
+
13
+ /**
14
+ * Normalise for table lookup: lowercase, trim, strip `"roku "`
15
+ * prefix so `"Roku Ultra"` and `"Ultra"` resolve identically.
16
+ */
17
+ function normalizeModel(modelName: string): string {
18
+ const lower = modelName.trim().toLowerCase();
19
+ return lower.startsWith("roku ") ? lower.slice("roku ".length) : lower;
20
+ }
21
+
22
+ /**
23
+ * Static `modelName` → tier table. Conservative: prefer `"unknown"`
24
+ * for new models until they're manually classified.
25
+ */
26
+ const MODEL_CLASS_TABLE: ReadonlyMap<string, DeviceClass> = new Map([
27
+ ["express", "low-end"],
28
+ ["express 4k", "mid-range"],
29
+ ["express 4k+", "mid-range"],
30
+ ["streaming stick", "low-end"],
31
+ ["streaming stick+", "mid-range"],
32
+ ["streaming stick 4k", "mid-range"],
33
+ ["streaming stick 4k+", "mid-range"],
34
+ ["premiere", "mid-range"],
35
+ ["premiere+", "mid-range"],
36
+ ["ultra", "high-end"],
37
+ ["ultra lt", "mid-range"],
38
+ // Roku TVs keyed by the strings ECP actually reports.
39
+ ["roku tv", "mid-range"],
40
+ ["4k roku tv", "mid-range"],
41
+ ["roku ultra lt", "mid-range"],
42
+ ["reference rsg-4k", "high-end"],
43
+ ["reference rsg-hd", "low-end"],
44
+ ["streambar", "low-end"],
45
+ ["streambar pro", "mid-range"],
46
+ ["wireless soundbar", "mid-range"]
47
+ ]);
48
+
49
+ /**
50
+ * Map `modelName` to a tier. Pure (no I/O, no state).
51
+ * `"unknown"` for unmapped models so preconditions fail closed.
52
+ */
53
+ export function getDeviceClass(modelName: string): DeviceClass {
54
+ if (!modelName) return "unknown";
55
+ const hit = MODEL_CLASS_TABLE.get(normalizeModel(modelName));
56
+ return hit ?? "unknown";
57
+ }
@@ -0,0 +1,58 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Synchronous `DeviceInfo` accessor backed by
5
+ * `test-results/device-env.json` (written by `globalSetup`).
6
+ *
7
+ * `itOnDevice`/`describeOnDevice` evaluate preconditions at
8
+ * registration time — before any `beforeAll` has fired, so the
9
+ * live `RkClient` has no cached info yet. Reading from disk is the
10
+ * simplest shape that preserves the "one ECP round-trip per suite"
11
+ * contract.
12
+ *
13
+ * Missing file → return null (caller falls back to "register
14
+ * normally", no skip) so a fresh clone boots cleanly.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { ARTIFACT_FILE } from "../constants";
21
+ import { TEST_RESULTS_DIR_NAME } from "../paths";
22
+ import type { DeviceInfo } from "../types";
23
+
24
+ // Path resolved lazily from cwd so a `chdir` before hooks still
25
+ // lands correctly. Consumers override via `setDeviceEnvFile()` to
26
+ // anchor on `__dirname` (robust to worker cwd changes).
27
+ let overridePath: string | null = null;
28
+
29
+ export function setDeviceEnvFile(absPath: string): void {
30
+ overridePath = absPath;
31
+ }
32
+
33
+ export function getDeviceEnvFile(): string {
34
+ return overridePath ?? join(process.cwd(), TEST_RESULTS_DIR_NAME, ARTIFACT_FILE.DEVICE_ENV);
35
+ }
36
+
37
+ /**
38
+ * Read and parse `device-env.json`. Returns null when missing or
39
+ * unparseable so precondition evaluators see "no cache" consistently.
40
+ * Synchronous because `itOnDevice`/`describeOnDevice` can't await.
41
+ */
42
+ export function readCachedDeviceInfoSync(): DeviceInfo | null {
43
+ const file = getDeviceEnvFile();
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ const raw = readFileSync(file, "utf8");
47
+ const parsed = JSON.parse(raw) as DeviceInfo;
48
+ if (!parsed || typeof parsed !== "object") return null;
49
+ return parsed;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Test-only reset for the `setDeviceEnvFile` override. */
56
+ export function resetDeviceEnvFileOverride(): void {
57
+ overridePath = null;
58
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Device IP resolution — sync side (worker-safe).
5
+ *
6
+ * Intentionally has no `@roku-sdk/tooling` imports: the tooling's
7
+ * device-store module is ESM and breaks CommonJS consumers at load
8
+ * time via `import.meta.url`. Keep this file import-light.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { DEVICE_IP_CACHE_FILE, ENV_VAR } from "../constants";
15
+ import { RkClientError } from "../errors";
16
+
17
+ /**
18
+ * Return the device IP to use from:
19
+ * 1. `RK_DEVICE_IP` env var, then
20
+ * 2. `testResultsDir/.device-ip` written by `globalSetup`.
21
+ *
22
+ * Throws `RkClientError(RK_DEVICE_IP_UNRESOLVED)` otherwise.
23
+ */
24
+ export function resolveDeviceIpSync(testResultsDir?: string): string {
25
+ const envIp = process.env[ENV_VAR.RK_DEVICE_IP];
26
+ if (envIp && envIp.length > 0) return envIp;
27
+
28
+ if (testResultsDir) {
29
+ try {
30
+ const cached = readFileSync(join(testResultsDir, DEVICE_IP_CACHE_FILE), "utf8").trim();
31
+ if (cached.length > 0) return cached;
32
+ } catch (err) {
33
+ const code = (err as NodeJS.ErrnoException).code;
34
+ if (code !== "ENOENT") throw err;
35
+ }
36
+ }
37
+
38
+ throw new RkClientError(
39
+ `Unable to resolve a Roku device IP. Set ${ENV_VAR.RK_DEVICE_IP}=<ip> or ensure ` +
40
+ `the suite's global-setup has run and written ${DEVICE_IP_CACHE_FILE}.`,
41
+ "RK_DEVICE_IP_UNRESOLVED"
42
+ );
43
+ }
@@ -0,0 +1,141 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Async device IP resolution. Split from the sync resolver so
5
+ * `createDefaultClient` never transitively loads
6
+ * `@roku-sdk/tooling`'s ESM `devices-store` (top-level
7
+ * `import.meta.url`) under CommonJS consumers like ts-jest.
8
+ *
9
+ * Flow: `globalSetup` calls `resolveActiveDeviceIp()` and persists
10
+ * via `persistDeviceIp`; worker processes read the persisted IP
11
+ * synchronously through `device-resolver-sync.ts`.
12
+ *
13
+ * Resolution precedence (later wins on conflict):
14
+ * 0. (Layer 0) `RK_DEVICE_LEASE_ID` — read the lease file
15
+ * `.roku/leases/<id>.json` and use its `ip`. This is the
16
+ * canonical CI path for provider-backed pools; the provider writes the
17
+ * lease at acquire-time and pins the consumer at boot.
18
+ * 1. `RK_DEVICE_IP` — partner-side override. Still WINS on
19
+ * conflict so existing partner pipelines that pin an IP
20
+ * keep working byte-for-byte after this file lands.
21
+ * 2. `.roku/` active device → first registered device.
22
+ *
23
+ * IMPORTANT: `RK_DEVICE_IP` deliberately wins over a lease file.
24
+ * Rationale:
25
+ * - Partner pipelines have set `RK_DEVICE_IP` for years; flipping
26
+ * precedence would silently break them when an unrelated lease
27
+ * file from a prior interactive `rk device lease acquire` is
28
+ * still on disk.
29
+ * - The internal CI path always sets `RK_DEVICE_LEASE_ID` AND
30
+ * leaves `RK_DEVICE_IP` unset, so the lease-file path "wins"
31
+ * in practice without any precedence ambiguity.
32
+ */
33
+
34
+ import { promises as fs } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ import { leaseFilePath, readLeaseFile } from "@roku-sdk/tooling/src/device-pool/lease-writer";
38
+ import { loadListMergedDevices } from "@roku-sdk/tooling/src/device-test-helpers";
39
+
40
+ import { DEVICE_IP_CACHE_FILE, ENV_VAR } from "../constants";
41
+ import { RkClientError } from "../errors";
42
+
43
+ // `loadListMergedDevices` lives on the tooling side (in
44
+ // `@roku-sdk/tooling/device-test-helpers`) so the deep-require path
45
+ // is owned by the package whose internals it references. Static
46
+ // import of `devices-store` would pull in `get-config.ts` (top-level
47
+ // `import.meta.url`) which is illegal under our CommonJS tsconfig;
48
+ // the helper defers resolution to runtime via `require()`.
49
+
50
+ export { resolveDeviceIpSync } from "./device-resolver-sync";
51
+
52
+ /**
53
+ * Layer 0 lookup: read the lease file the device-pool provider wrote
54
+ * for this job and return its `ip` if usable.
55
+ *
56
+ * Returns `undefined` (not throws) on every recoverable miss — env
57
+ * unset, lease dir missing, file corrupt — so a higher precedence
58
+ * layer (`RK_DEVICE_IP`) or a lower one (`.roku/` active) can still
59
+ * resolve a valid IP. The one fatal case is "env says use lease X
60
+ * but no other layer has an IP either"; that surfaces at the bottom
61
+ * of `resolveActiveDeviceIp` as the existing `RK_DEVICE_IP_UNRESOLVED`
62
+ * error, which already has the actionable remediation steps.
63
+ *
64
+ * We deliberately do NOT mutate `.roku/roku-dev-config.json` or
65
+ * `rk-server` state — the lease file is the single source of truth,
66
+ * and `rk-server` (when started in a job with `RK_DEVICE_LEASE_ID`
67
+ * set) reads through the same file directly. This is what makes the
68
+ * parallel-CI model safe: workspace-singleton state cannot race.
69
+ */
70
+ async function resolveFromLeaseFile(cwd: string): Promise<string | undefined> {
71
+ const leaseId = process.env[ENV_VAR.RK_DEVICE_LEASE_ID];
72
+ if (!leaseId || leaseId.length === 0) return undefined;
73
+ const leaseDir = process.env[ENV_VAR.RK_DEVICE_LEASE_DIR] ?? join(cwd, ".roku", "leases");
74
+ try {
75
+ const lease = await readLeaseFile(leaseId, leaseDir);
76
+ if (typeof lease.ip === "string" && lease.ip.length > 0) {
77
+ return lease.ip;
78
+ }
79
+ return undefined;
80
+ } catch {
81
+ // `readLeaseFile` throws `DevicePoolError(INVALID_LEASE_FILE)`
82
+ // on ENOENT / malformed JSON / schema mismatch. We treat that
83
+ // as "Layer 0 didn't resolve" and let the higher / lower
84
+ // layers continue. A truly broken lease file is logged louder
85
+ // by the CLI verbs that wrote it (acquire/reap); the resolver
86
+ // is not the right place to surface that.
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve the device IP. See file-level doc-block for the precedence
93
+ * order. Throws `RkClientError(RK_DEVICE_IP_UNRESOLVED)` with an
94
+ * actionable message when no layer yields an IP.
95
+ */
96
+ export async function resolveActiveDeviceIp(cwd: string = process.cwd()): Promise<string> {
97
+ const envIp = process.env[ENV_VAR.RK_DEVICE_IP];
98
+ if (envIp && envIp.length > 0) return envIp;
99
+
100
+ const leaseIp = await resolveFromLeaseFile(cwd);
101
+ if (leaseIp) return leaseIp;
102
+
103
+ const listMergedDevices = loadListMergedDevices();
104
+ const { devices, activeId, activeRceId } = await listMergedDevices(cwd);
105
+ if (activeRceId) {
106
+ throw new RkClientError(
107
+ `The active device ${activeRceId} is a Roku Cloud Emulator device; device tests run on LAN devices only. ` +
108
+ `Set ${ENV_VAR.RK_DEVICE_IP}=<ip> or run \`rk device set-active <lan-device>\`.`,
109
+ "DEVICE_KIND_UNSUPPORTED"
110
+ );
111
+ }
112
+ if (activeId) {
113
+ const active = devices.find(d => d.id === activeId);
114
+ if (active?.ip) return active.ip;
115
+ }
116
+
117
+ if (devices.length > 0 && devices[0]?.ip) return devices[0].ip;
118
+
119
+ throw new RkClientError(
120
+ `Unable to resolve a Roku device IP. Set ${ENV_VAR.RK_DEVICE_IP}=<ip>, ` +
121
+ `set ${ENV_VAR.RK_DEVICE_LEASE_ID}=<leaseId> after \`rk device lease acquire --target <ipv4>\` ` +
122
+ `(no \`rk device add\` required for raw IPs), or run \`rk device add <ip>\` in the workspace, ` +
123
+ `then re-run the suite. IPv6 and hostnames are not supported by the local pool.`,
124
+ "RK_DEVICE_IP_UNRESOLVED"
125
+ );
126
+ }
127
+
128
+ // Re-export `leaseFilePath` so consumers (e.g. `rk-server`) that
129
+ // need to construct the same path as the resolver have a single
130
+ // source of truth. Avoids two implementations drifting on
131
+ // `.roku/leases/<id>.json` layout.
132
+ export { leaseFilePath };
133
+
134
+ /**
135
+ * Write the resolved IP into `testResultsDir/.device-ip` so worker
136
+ * processes (spawned after globalSetup) can read it synchronously.
137
+ */
138
+ export async function persistDeviceIp(testResultsDir: string, ip: string): Promise<void> {
139
+ await fs.mkdir(testResultsDir, { recursive: true });
140
+ await fs.writeFile(join(testResultsDir, DEVICE_IP_CACHE_FILE), ip, "utf8");
141
+ }
@@ -0,0 +1,54 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Provisioning facts the device-pool provider recorded on the lease.
5
+ *
6
+ * These describe how the device was *prepared*, which no ECP query can
7
+ * answer: `/query/device-info` reports the running `ui-software` version
8
+ * but not which OS image produced it. `globalSetup` folds them into the
9
+ * `device-env.json` artifact so a red CI run carries its own provenance.
10
+ *
11
+ * The lease file — not an env var — is the source of truth. Provider-owned
12
+ * env vars are Roku-internal and deliberately unknown to this package; the
13
+ * lease is the generic, already-sanctioned hand-off (the same one
14
+ * `device-resolver.ts` reads the IP from), and it records what the provider
15
+ * *actually* booted rather than what CI *asked* for.
16
+ *
17
+ * Every miss degrades to `{}`: no lease (local desk run), a provider that
18
+ * flashes nothing (`local`, say), a corrupt file, or an older provider
19
+ * that predates the rider. The artifact simply omits the field — absence
20
+ * means "unknown", never "none".
21
+ */
22
+
23
+ import { join } from "node:path";
24
+
25
+ import { readLeaseFile } from "@roku-sdk/tooling/src/device-pool/lease-writer";
26
+
27
+ import { ENV_VAR, LEASE_FIELD } from "../constants";
28
+
29
+ export interface LeaseProvisioning {
30
+ /** See `LEASE_FIELD.ROOTFS_ARTIFACTORY_PATH`. Absent when unknown. */
31
+ rootfsArtifactoryPath?: string;
32
+ }
33
+
34
+ /**
35
+ * Read the provisioning riders off this job's lease. Never throws — a
36
+ * diagnostics-only artifact field must not be able to fail a suite that
37
+ * would otherwise pass.
38
+ */
39
+ export async function readLeaseProvisioning(cwd: string = process.cwd()): Promise<LeaseProvisioning> {
40
+ const leaseId = process.env[ENV_VAR.RK_DEVICE_LEASE_ID];
41
+ if (!leaseId || leaseId.length === 0) return {};
42
+ const leaseDir = process.env[ENV_VAR.RK_DEVICE_LEASE_DIR] ?? join(cwd, ".roku", "leases");
43
+ try {
44
+ const lease = await readLeaseFile(leaseId, leaseDir);
45
+ const rootfs = (lease as Record<string, unknown>)[LEASE_FIELD.ROOTFS_ARTIFACTORY_PATH];
46
+ return typeof rootfs === "string" && rootfs.length > 0 ? { rootfsArtifactoryPath: rootfs } : {};
47
+ } catch {
48
+ // `readLeaseFile` throws on ENOENT / malformed JSON / schema
49
+ // mismatch. The IP resolver already ran against the same file and
50
+ // reports a genuinely broken lease far more loudly than a
51
+ // provenance field ever should, so stay silent here.
52
+ return {};
53
+ }
54
+ }