@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,43 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Stateless env guards for baseline writes. Leaf module: must not
5
+ * import from `client.ts`, `assertions.ts`, or `jest-setup.ts` to
6
+ * break the circular import that arose when assertions needed a CI
7
+ * guard reachable via jest-setup.
8
+ */
9
+
10
+ import { ENV_VAR } from "../constants";
11
+
12
+ // Symmetric across `CI` and `UPDATE_BASELINES` so the bash
13
+ // pre-flight and this code-side guard agree on what "set" means.
14
+ const TRUTHY_VALUES: ReadonlySet<string> = new Set(["1", "true", "yes"]);
15
+
16
+ function isTruthyEnv(raw: string | undefined): boolean {
17
+ if (raw === undefined || raw === "") return false;
18
+ return TRUTHY_VALUES.has(raw.toLowerCase());
19
+ }
20
+
21
+ export function isCi(): boolean {
22
+ return isTruthyEnv(process.env[ENV_VAR.CI]);
23
+ }
24
+
25
+ /**
26
+ * True iff `UPDATE_BASELINES` is set to a truthy spelling
27
+ * (`1`/`true`/`yes`, case-insensitive).
28
+ */
29
+ export function updateBaselinesRequested(): boolean {
30
+ return isTruthyEnv(process.env[ENV_VAR.UPDATE_BASELINES]);
31
+ }
32
+
33
+ /**
34
+ * Throws if `UPDATE_BASELINES` is set in CI. Mirrors the bash
35
+ * pre-flight in `.gitlab-ci.yml`.
36
+ */
37
+ export function assertBaselineUpdatesAllowed(): void {
38
+ if (isCi() && updateBaselinesRequested()) {
39
+ throw new Error(
40
+ `${ENV_VAR.UPDATE_BASELINES}=1 is not allowed in CI. ` + "Update baselines locally and commit them."
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,178 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Baseline path resolver. Module-level state (vs class) because
5
+ * `expectTree` has no convenient DI point and Jest runs one
6
+ * RkClient per worker.
7
+ *
8
+ * Resolution subdir precedence:
9
+ * 1. Active client + recognised `videoMode` → mapped resolution
10
+ * (env override deliberately NOT consulted here).
11
+ * 2. Active client + unrecognised `videoMode` → throw
12
+ * `BaselineResolutionError({reason:"unsupported-videoMode"})`.
13
+ * We refuse env fallback so firmware drift fails fast.
14
+ * 3. No active client → consult `DEVICE_TEST_RESOLUTION`.
15
+ */
16
+
17
+ import { randomBytes } from "node:crypto";
18
+ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
19
+
20
+ import { BASELINE_RESOLUTION_DIR, ENV_VAR } from "../constants";
21
+ import { BaselineResolutionError } from "../errors";
22
+ import type { DeviceInfo } from "../types";
23
+
24
+ /** Minimal `RkClient` shape; avoids a circular import. */
25
+ export interface ActiveClientLike {
26
+ cachedDeviceInfo(): DeviceInfo | null;
27
+ }
28
+
29
+ export type BaselineResolution = "hd" | "fhd";
30
+
31
+ let baselineRoot: string | null = null;
32
+ let activeClient: ActiveClientLike | null = null;
33
+
34
+ /** Register the absolute path to the consumer's `baselines/` directory. */
35
+ export function setBaselineRoot(absPath: string): void {
36
+ baselineRoot = absPath;
37
+ }
38
+
39
+ /**
40
+ * Register the shared `RkClient` so baseline resolution can read its
41
+ * cached `videoMode`. Pass `null` to clear.
42
+ */
43
+ export function setActiveClient(client: ActiveClientLike | null): void {
44
+ activeClient = client;
45
+ }
46
+
47
+ /**
48
+ * Map `DeviceInfo.videoMode` to baseline subdir: 720p → hd, 1080p →
49
+ * fhd. Returns null for unknown modes.
50
+ */
51
+ export function videoModeToResolution(videoMode: string): BaselineResolution | null {
52
+ if (videoMode === "720p" || videoMode === "720p60") return "hd";
53
+ if (videoMode === "1080p" || videoMode === "1080p60") return "fhd";
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Map an observed canvas size to the matching baseline subdir. This is
59
+ * intentionally separate from `videoModeToResolution`: fixture apps that
60
+ * do not set `ui_resolutions=fhd` can render/capture at 1280x720 even on
61
+ * a device whose output video mode reports 1080p.
62
+ */
63
+ export function canvasSizeToResolution(width: number, height: number): BaselineResolution | null {
64
+ if (width === 1280 && height === 720) return "hd";
65
+ if (width === 1920 && height === 1080) return "fhd";
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Resolve `{hd|fhd}` subdir per precedence at top of module.
71
+ * Throws `BaselineResolutionError` with a discriminated `reason`
72
+ * for each failure mode.
73
+ */
74
+ export function resolveResolution(): {
75
+ resolution: BaselineResolution;
76
+ videoMode: string | undefined;
77
+ } {
78
+ // Precedence 1/2: active client is the authoritative signal.
79
+ if (activeClient) {
80
+ const info = activeClient.cachedDeviceInfo() ?? null;
81
+ const videoMode = info?.videoMode ?? "";
82
+ if (videoMode) {
83
+ const res = videoModeToResolution(videoMode);
84
+ if (res) return { resolution: res, videoMode };
85
+ throw new BaselineResolutionError(
86
+ `Cannot resolve baseline resolution: active RkClient reports ` +
87
+ `unsupported videoMode "${videoMode}". Supported values: ` +
88
+ `720p, 720p60, 1080p, 1080p60. To bypass the active client ` +
89
+ `(e.g. for an ad-hoc smoke run), first call ` +
90
+ `setActiveClient(null) and then set ` +
91
+ `${ENV_VAR.DEVICE_TEST_RESOLUTION}=hd|fhd. The env ` +
92
+ `override is intentionally ignored while a client is ` +
93
+ `registered so unsupported firmware modes fail fast ` +
94
+ `instead of silently using the wrong baseline subdir.`,
95
+ { videoMode, reason: "unsupported-videoMode" }
96
+ );
97
+ }
98
+ // Cache empty/blank: treat as "no signal" and fall through.
99
+ // Rare; globalSetup seeds the cache before tests run.
100
+ }
101
+
102
+ // Precedence 3: env override.
103
+ const envRaw = process.env[ENV_VAR.DEVICE_TEST_RESOLUTION];
104
+ if (envRaw === undefined || envRaw === "") {
105
+ throw new BaselineResolutionError(
106
+ `Cannot resolve baseline resolution: no active RkClient with ` +
107
+ `cached DeviceInfo.videoMode, and ${ENV_VAR.DEVICE_TEST_RESOLUTION} ` +
108
+ `is not set. Register the client via setActiveClient(client) ` +
109
+ `in your setup file, or export ` +
110
+ `${ENV_VAR.DEVICE_TEST_RESOLUTION}=hd|fhd for ad-hoc runs.`,
111
+ { reason: "no-active-client-and-no-env" }
112
+ );
113
+ }
114
+ if (envRaw !== "hd" && envRaw !== "fhd") {
115
+ throw new BaselineResolutionError(
116
+ `Cannot resolve baseline resolution: ` +
117
+ `${ENV_VAR.DEVICE_TEST_RESOLUTION}="${envRaw}" is not a valid ` +
118
+ `value. Supported: "hd", "fhd".`,
119
+ { reason: "invalid-env-value" }
120
+ );
121
+ }
122
+ return { resolution: envRaw, videoMode: undefined };
123
+ }
124
+
125
+ /**
126
+ * Atomic write via tmp-file + same-dir rename. Tmp suffix is
127
+ * pid+random so concurrent writers can't clobber each other's tmp
128
+ * and a crashed writer can't leave a stale tmp another writer would
129
+ * rename over. Cleanup targets the unique tmp this invocation
130
+ * created. Accepts string (utf8) or Buffer (binary).
131
+ */
132
+ export function atomicWriteFileSync(absPath: string, contents: string | Buffer): void {
133
+ const tmp = `${absPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
134
+ try {
135
+ if (typeof contents === "string") {
136
+ writeFileSync(tmp, contents, "utf8");
137
+ } else {
138
+ writeFileSync(tmp, contents);
139
+ }
140
+ renameSync(tmp, absPath);
141
+ } catch (err) {
142
+ try {
143
+ unlinkSync(tmp);
144
+ } catch {
145
+ // best-effort cleanup
146
+ }
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Absolute on-disk path for a baseline fragment.
153
+ * Root precedence: `setBaselineRoot()` > `DEVICE_TEST_BASELINES` env
154
+ * > `<cwd>/baselines`. Subdir comes from `resolveResolution()`.
155
+ */
156
+ export function resolveBaselinePath(
157
+ baselinePath: string,
158
+ opts?: { resolution?: BaselineResolution; videoMode?: string }
159
+ ): {
160
+ absolutePath: string;
161
+ resolution: BaselineResolution;
162
+ videoMode: string | undefined;
163
+ } {
164
+ const root = baselineRoot ?? process.env[ENV_VAR.DEVICE_TEST_BASELINES] ?? `${process.cwd()}/baselines`;
165
+ const { resolution, videoMode } = opts?.resolution
166
+ ? { resolution: opts.resolution, videoMode: opts.videoMode }
167
+ : resolveResolution();
168
+ const subdir = BASELINE_RESOLUTION_DIR[resolution];
169
+ const cleaned = baselinePath.replace(/^\/+/, "");
170
+ const absolutePath = `${root}/${subdir}/${cleaned}`;
171
+ return { absolutePath, resolution, videoMode };
172
+ }
173
+
174
+ /** Test-only: clear module state. Not re-exported from `index.ts`. */
175
+ export function __resetBaselineStateForTests(): void {
176
+ baselineRoot = null;
177
+ activeClient = null;
178
+ }
@@ -0,0 +1,39 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Module-level diff-root for screenshot failure PNGs. Leaf module
5
+ * (no `client.ts`/`assertions.ts` imports) so both `assertions.ts`
6
+ * and `jest-setup.ts` can read/write it without circular imports.
7
+ *
8
+ * Precedence: explicit `setScreenshotDiffRoot()` wins; otherwise
9
+ * `<process.cwd()>/test-results/diffs` resolved lazily on read.
10
+ */
11
+
12
+ import { join } from "node:path";
13
+
14
+ import { SCREENSHOT_DIFF_DIR } from "../constants";
15
+
16
+ let diffRoot: string | null = null;
17
+
18
+ /**
19
+ * Register the absolute directory for screenshot diff PNGs.
20
+ * Caller should resolve to an absolute path so workers with
21
+ * divergent cwds still write into the same directory.
22
+ */
23
+ export function setScreenshotDiffRoot(absPath: string): void {
24
+ diffRoot = absPath;
25
+ }
26
+
27
+ /**
28
+ * Configured diff root, or `<cwd>/test-results/diffs` lazily so
29
+ * `process.chdir()` between setup and test doesn't stale the path.
30
+ */
31
+ export function getScreenshotDiffRoot(): string {
32
+ if (diffRoot !== null) return diffRoot;
33
+ return join(process.cwd(), SCREENSHOT_DIFF_DIR);
34
+ }
35
+
36
+ /** Test-only: clear module state. Not re-exported from `index.ts`. */
37
+ export function __resetScreenshotDiffRootForTests(): void {
38
+ diffRoot = null;
39
+ }