@roku-sdk/device-test-client 0.0.0-stage → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BREAKING_CHANGES.md +17 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/baselines.md +221 -0
- package/docs/configuration.md +258 -0
- package/docs/dom-assertions.md +264 -0
- package/docs/errors.md +544 -0
- package/docs/examples/deep-link-cold-start.md +85 -0
- package/docs/examples/focus-navigation.md +81 -0
- package/docs/examples/log-driven-assertion.md +99 -0
- package/docs/examples/precondition-skips.md +150 -0
- package/docs/examples/screenshot-regression.md +103 -0
- package/docs/examples/smoke-test.md +69 -0
- package/docs/getting-started.md +195 -0
- package/docs/jest-integration.md +331 -0
- package/docs/log-assertions.md +349 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +657 -0
- package/docs/screenshot-assertions.md +316 -0
- package/package.json +81 -3
- package/src/assertions/assertions.ts +661 -0
- package/src/assertions/baseline-env-guard.ts +43 -0
- package/src/assertions/baselines.ts +178 -0
- package/src/assertions/screenshot-artifacts.ts +39 -0
- package/src/assertions/screenshot-diff.ts +447 -0
- package/src/client/build-cache.ts +292 -0
- package/src/client/client.ts +1964 -0
- package/src/client/deploy.ts +407 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +170 -0
- package/src/client/health.ts +345 -0
- package/src/client/parsers.ts +162 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +520 -0
- package/src/device/device-class.ts +57 -0
- package/src/device/device-env-sync.ts +58 -0
- package/src/device/device-resolver-sync.ts +43 -0
- package/src/device/device-resolver.ts +141 -0
- package/src/device/lease-provisioning.ts +54 -0
- package/src/errors.ts +774 -0
- package/src/index.ts +181 -0
- package/src/internal/constants.ts +3 -0
- package/src/jest/artifacts.ts +87 -0
- package/src/jest/device-test-environment.ts +420 -0
- package/src/jest/failure-artifacts.ts +169 -0
- package/src/jest/global-setup.ts +137 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +490 -0
- package/src/jest/jest-setup.ts +53 -0
- package/src/jest/preconditions.ts +239 -0
- package/src/jest/telemetry.ts +331 -0
- package/src/logs/device-log-sink.ts +69 -0
- package/src/logs/log-buffer.ts +278 -0
- package/src/logs/log-session.ts +436 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +472 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- package/support/paths.ts +13 -0
|
@@ -0,0 +1,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
|
+
}
|