@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,133 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `createGlobalSetup` — factory for Jest's `globalSetup` config.
5
+ * Runs once per suite, in its own process, before any worker
6
+ * imports a test file. `jest-setup.ts` re-exports for API
7
+ * compatibility.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+
12
+ import { assertBaselineUpdatesAllowed } from "../assertions/baseline-env-guard";
13
+ import { createDefaultClient } from "../client/factory";
14
+ import { ARTIFACT_FILE, ENV_VAR, ROKU_LOG_PORT } from "../constants";
15
+ import { persistDeviceIp, resolveActiveDeviceIp } from "../device/device-resolver";
16
+ import { readLeaseProvisioning } from "../device/lease-provisioning";
17
+ import {
18
+ assertSupportedVideoMode,
19
+ cleanArtifacts,
20
+ logDeviceDiagnostic,
21
+ trimTrailingSlash,
22
+ writeDeviceEnvSnapshot
23
+ } from "./artifacts";
24
+ import {
25
+ buildAcquireEvent,
26
+ emitDeviceLeaseLog,
27
+ resolveTelemetry,
28
+ writeJUnitTelemetryPropertiesFile
29
+ } from "./telemetry";
30
+
31
+ export interface GlobalSetupOptions {
32
+ /** Absolute path to the consumer's `test-results/` directory. */
33
+ testResultsDir: string;
34
+ }
35
+
36
+ /**
37
+ * ECP budget for the startup readiness gate, deliberately tighter than
38
+ * the client-wide `DEVICE_ONLINE_DEFAULT_TIMEOUT_MS` (which is sized for
39
+ * crash/reboot recovery mid-run). By the time `globalSetup` runs the
40
+ * lease layer has already waited for ECP, so a device that still isn't
41
+ * answering is misconfigured or powered off — failing in 30s keeps that
42
+ * diagnosis fast for local runs instead of hanging for minutes.
43
+ */
44
+ const STARTUP_ECP_TIMEOUT_MS = 30_000;
45
+
46
+ /**
47
+ * Build the `globalSetup` function:
48
+ * 1. Reject `UPDATE_BASELINES=1` in CI.
49
+ * 2. Clean stale artifacts (keeps `.gitkeep`).
50
+ * 3. Resolve device IP and persist `test-results/.device-ip`
51
+ * (workers read synchronously). Also exports `RK_DEVICE_IP`.
52
+ * 4. Block until the device answers ECP *and* accepts TCP on the
53
+ * sideload installer port; validate `videoMode`.
54
+ * 5. Persist `device-env.json` — the ECP snapshot plus the lease's
55
+ * provisioning provenance; workers seed cache from it.
56
+ * 6. Log one-line diagnostic.
57
+ * 7. Reset + start `rk server` log session (best-effort; a
58
+ * missing daemon is logged not thrown).
59
+ * 8. Emit the `[device-lease]` acquire log line and write the
60
+ * JUnit per-`<testcase>` properties file (`device-id`,
61
+ * `shard-name`, `providerKind`). Done LAST so a failure in
62
+ * the device-readiness path (steps 3–7) short-circuits before
63
+ * we publish a "we successfully took ownership" telemetry
64
+ * record. The matching release line is emitted by
65
+ * `globalTeardown`.
66
+ */
67
+ export function createGlobalSetup(opts: GlobalSetupOptions): () => Promise<void> {
68
+ return async function globalSetup(): Promise<void> {
69
+ assertBaselineUpdatesAllowed();
70
+ cleanArtifacts(opts.testResultsDir);
71
+
72
+ const ip = await resolveActiveDeviceIp();
73
+ await persistDeviceIp(opts.testResultsDir, ip);
74
+ process.env[ENV_VAR.RK_DEVICE_IP] = ip;
75
+
76
+ const client = createDefaultClient({ deviceIp: ip });
77
+ // Gate on ECP *and* the sideload installer, not ECP alone. ECP is
78
+ // the first port Roku OS brings up; the installer trails it, so a
79
+ // single `/query/device-info` says nothing about whether the very
80
+ // first `plugin_install` of the run will be accepted. Paying the
81
+ // wait here makes the cost visible and shared, instead of dumping
82
+ // it on whichever suite happens to be scheduled first.
83
+ const info = await client.waitForDeviceFullyReady({ ecpTimeoutMs: STARTUP_ECP_TIMEOUT_MS });
84
+ assertSupportedVideoMode(info.videoMode);
85
+ const provisioning = await readLeaseProvisioning();
86
+ writeDeviceEnvSnapshot(opts.testResultsDir, info, provisioning);
87
+ logDeviceDiagnostic(info, provisioning);
88
+
89
+ await resetAndStartLogSession(client.getServerUrl(), ip);
90
+
91
+ // Telemetry must come AFTER device readiness so we never
92
+ // publish an acquire record for a run that is about to
93
+ // throw at startup (skewed wall-time + device-utilization
94
+ // baselines downstream).
95
+ const telemetry = resolveTelemetry({ deviceId: ip });
96
+ writeJUnitTelemetryPropertiesFile(join(opts.testResultsDir, ARTIFACT_FILE.JUNIT_CASE_PROPERTIES), telemetry);
97
+ emitDeviceLeaseLog(buildAcquireEvent(telemetry));
98
+ };
99
+ }
100
+
101
+ /**
102
+ * `/logs/stop` (idempotent) + `/logs/start` so every worker SSE
103
+ * subscriber sees an identical starting buffer. Swallows transport
104
+ * errors so suites without log assertions still run.
105
+ */
106
+ async function resetAndStartLogSession(serverUrl: string, deviceIp: string): Promise<void> {
107
+ const stopUrl = `${trimTrailingSlash(serverUrl)}/logs/stop`;
108
+ const startUrl = `${trimTrailingSlash(serverUrl)}/logs/start`;
109
+ const body = JSON.stringify({ device: deviceIp, port: ROKU_LOG_PORT });
110
+ const jsonHeaders = { "Content-Type": "application/json" };
111
+ try {
112
+ // Idempotent stop. Only warn on transport failures so a
113
+ // misconfigured daemon shows up in CI output.
114
+ await fetch(stopUrl, { method: "POST", headers: jsonHeaders, body }).catch(err => {
115
+ console.warn(`[device-test-client] /logs/stop failed (ignored): ${err.message}`);
116
+ });
117
+ const res = await fetch(startUrl, {
118
+ method: "POST",
119
+ headers: jsonHeaders,
120
+ body
121
+ });
122
+ if (!res.ok && res.status !== 400) {
123
+ console.warn(
124
+ `[device-test-client] /logs/start returned ${res.status} — log assertions may not work this run.`
125
+ );
126
+ }
127
+ } catch (err) {
128
+ console.warn(
129
+ `[device-test-client] rk server unreachable at ${serverUrl} (${(err as Error).message}); ` +
130
+ `log assertions will fail fast on first use.`
131
+ );
132
+ }
133
+ }
@@ -0,0 +1,132 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * `createGlobalTeardown` — factory for the function a consumer passes
5
+ * to Jest's `globalTeardown` config key.
6
+ *
7
+ * Extracted from `jest-setup.ts` so the `globalTeardown` wiring lives
8
+ * alongside `global-setup.ts` as a mirror pair. `jest-setup.ts`
9
+ * re-exports `createGlobalTeardown` and `CreateGlobalTeardownOptions`
10
+ * for API compatibility.
11
+ */
12
+
13
+ import { ECP_PORT } from "@roku-sdk/tooling/src/device-test-helpers";
14
+
15
+ import { resolveServerUrl } from "../client/server-url";
16
+ import { ENV_VAR, ROKU_LOG_PORT } from "../constants";
17
+ import { trimTrailingSlash } from "./artifacts";
18
+ import { buildReleaseEvent, emitDeviceLeaseLog, resolveTelemetry } from "./telemetry";
19
+
20
+ /**
21
+ * Options for `createGlobalTeardown`.
22
+ */
23
+ export interface CreateGlobalTeardownOptions {
24
+ /** Override the `rk server` URL (defaults to the shared resolver). */
25
+ serverUrl?: string;
26
+ /** Override the device IP (defaults to `RK_DEVICE_IP`). */
27
+ deviceIp?: string;
28
+ /**
29
+ * Whether to send ECP `/keypress/Home` to the device at the end of
30
+ * the run so the sideloaded dev channel exits to the home screen
31
+ * instead of being left on the last test's scene.
32
+ *
33
+ * Defaults to `true` because leaving the channel active between
34
+ * runs is an almost-universal papercut: the next `buildAndDeploy`
35
+ * sideloads over a live channel (slightly slower install + noisier
36
+ * logs), the device's screen burns the last fixture until the user
37
+ * intervenes, and if the last test tripped an uncaught effect the
38
+ * channel keeps emitting log lines after the Jest process exits.
39
+ * Best-effort — teardown never throws on transport failure, the
40
+ * same contract as the `/logs/stop` POST below.
41
+ *
42
+ * Set to `false` if you explicitly want to inspect the last
43
+ * scene on the device after the run (e.g. manual screenshot
44
+ * debugging).
45
+ */
46
+ exitToHome?: boolean;
47
+ }
48
+
49
+ /**
50
+ * Builds the function passed to Jest's `globalTeardown`. Three jobs:
51
+ *
52
+ * 1. Emit the `[device-lease]` release log line (telemetry).
53
+ * Done FIRST so the dashboard gets a release record even if
54
+ * the best-effort I/O steps below stall on a half-dead
55
+ * device and the process is reaped externally.
56
+ * 2. Send ECP `/keypress/Home` to the device so the sideloaded dev
57
+ * channel exits to the home screen. Avoids leaving the last
58
+ * test's fixture on-screen between runs. Default-on; opt out
59
+ * via `opts.exitToHome = false`. See `CreateGlobalTeardownOptions.exitToHome`
60
+ * for the rationale.
61
+ * 3. Stop the `rk server` log session started in `createGlobalSetup`
62
+ * so the daemon's connection to the device is released between
63
+ * suites.
64
+ *
65
+ * Steps 2 and 3 are best-effort — errors are logged, not thrown.
66
+ * Teardown must not fail the run (Jest exits non-zero if it does,
67
+ * masking the real test status).
68
+ *
69
+ * Step 2 → 3 order: Home first, then `/logs/stop`. Exiting the
70
+ * channel while the log tail is still attached lets the
71
+ * `AppExitInitiate` beacon show up in the session buffer, which is
72
+ * occasionally useful for post-mortem debugging if a suite fails;
73
+ * the subsequent `/logs/stop` cleanly releases the SSE regardless.
74
+ */
75
+ export function createGlobalTeardown(opts: CreateGlobalTeardownOptions = {}): () => Promise<void> {
76
+ return async function globalTeardown(): Promise<void> {
77
+ // Shared resolver (see `server-url.ts`) — same precedence as
78
+ // `RkClient` so the teardown hits the same daemon the worker
79
+ // talked to during the suite, including the `.roku/
80
+ // rk-server.json` discovery path.
81
+ const serverUrl = resolveServerUrl({ serverUrl: opts.serverUrl });
82
+ const deviceIp = opts.deviceIp ?? process.env[ENV_VAR.RK_DEVICE_IP];
83
+ // No `deviceIp` ⇒ globalSetup never published an acquire
84
+ // record (it threw before step 8) ⇒ no symmetric release
85
+ // record to emit. Skipping keeps the acquire/release pair
86
+ // invariant for downstream telemetry.
87
+ if (!deviceIp) return;
88
+
89
+ // Emit the release log line FIRST. Best-effort I/O below
90
+ // (ECP `Home`, `/logs/stop`) can stall on a half-dead
91
+ // device; emitting first guarantees the dashboard gets a
92
+ // release record even if those calls hang to their
93
+ // timeouts and the process is reaped externally. See
94
+ // `globalSetup` for the symmetric ordering rationale on
95
+ // the acquire side.
96
+ emitDeviceLeaseLog(buildReleaseEvent(resolveTelemetry({ deviceId: deviceIp })));
97
+
98
+ if (opts.exitToHome !== false) {
99
+ // Direct ECP — no queue, no retry. The dev channel is
100
+ // exiting either way; a transient network blip just means
101
+ // it stays active until the next run's `buildAndDeploy`
102
+ // replaces it. Short timeout so a dead device never stalls
103
+ // Jest's exit.
104
+ const homeUrl = `http://${deviceIp}:${ECP_PORT}/keypress/Home`;
105
+ try {
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), 2_000);
108
+ try {
109
+ await fetch(homeUrl, {
110
+ method: "POST",
111
+ signal: controller.signal
112
+ });
113
+ } finally {
114
+ clearTimeout(timer);
115
+ }
116
+ } catch (err) {
117
+ console.warn(`[device-test-client] globalTeardown: POST ${homeUrl} failed: ${(err as Error).message}`);
118
+ }
119
+ }
120
+
121
+ const url = `${trimTrailingSlash(serverUrl)}/logs/stop`;
122
+ try {
123
+ await fetch(url, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ device: deviceIp, port: ROKU_LOG_PORT })
127
+ });
128
+ } catch (err) {
129
+ console.warn(`[device-test-client] globalTeardown: POST ${url} failed: ${(err as Error).message}`);
130
+ }
131
+ };
132
+ }