@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,407 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Deploy-adjacent helpers used by `RkClient.buildAndDeploy` /
5
+ * `launch` / `buildAndLaunchWithParams`. Pure-ish: workspace root
6
+ * discovery, manifest parsing, launch URL composition, tooling
7
+ * loader. The build/sideload/launch methods themselves stay on
8
+ * `RkClient` because they need the live ECP client, log session,
9
+ * and health singleton.
10
+ */
11
+
12
+ import { existsSync, statSync } from "node:fs";
13
+ import { join, resolve as resolvePath } from "node:path";
14
+
15
+ import {
16
+ ECP_PORT,
17
+ loadToolingDeps as loadToolingDepsFromHelpers,
18
+ type RokuDeviceInstance as ToolingRokuDeviceInstance,
19
+ type ToolingDeps as ToolingDepsFromHelpers,
20
+ type ToolingDevice as ToolingDeviceFromHelpers,
21
+ ToolingError,
22
+ validateAndNormalizeKey
23
+ } from "@roku-sdk/tooling/src/device-test-helpers";
24
+
25
+ import { BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS, ENV_VAR } from "../constants";
26
+ import { RkClientError } from "../errors";
27
+ import { trimTrailingSlash } from "../jest/artifacts";
28
+
29
+ export function normalizeKeyOrThrow(key: string): string {
30
+ const result = validateAndNormalizeKey(key);
31
+ if (!result.valid) {
32
+ throw new RkClientError(result.error ?? `Invalid Roku key: ${key}`, "ECP_INVALID_KEY");
33
+ }
34
+ return result.normalizedKey;
35
+ }
36
+
37
+ /**
38
+ * `POST /api/build` payload subset — only fields `buildAndDeploy`
39
+ * needs. Server-side `pkg`, `bumpMinor`, etc. are deliberately
40
+ * omitted; add explicitly here if a test path needs them.
41
+ */
42
+ export interface ServerBuildRequest {
43
+ /** Absolute (or rk-server-cwd-relative) path to the app root. */
44
+ root: string;
45
+ /** Out directory relative to `root`. */
46
+ outDir: string;
47
+ /** Optional override for `roku-config.dropConsole`. */
48
+ dropConsole?: boolean;
49
+ /**
50
+ * Build with development artifacts — no minification, readable identifiers,
51
+ * source maps. Equivalent to `rk build --debug`. Useful for profiling sessions
52
+ * where readable function names in tracepoints matter.
53
+ */
54
+ dev?: boolean;
55
+ /**
56
+ * Enable SolidJS dev mode — imports the `solid-js/dev` build so signal
57
+ * and effect names remain readable in Perfetto tracepoints. Equivalent to
58
+ * `rk build --solid-dev-mode`.
59
+ */
60
+ solidDevMode?: boolean;
61
+ /**
62
+ * Build-time variable overrides merged on top of the app's `roku-config.ts`
63
+ * `buildVariables`. Pass `{ __SIGNAL__: true }` to enable SolidJS dev mode
64
+ * (readable signal/effect names in Perfetto tracepoints); other boolean
65
+ * entries are forwarded as-is.
66
+ */
67
+ buildVariables?: Record<string, boolean | string | number>;
68
+ /** Build type for the orchestrator session. Defaults to "build". */
69
+ buildType?: string;
70
+ /**
71
+ * Skip the whole-program TypeScript semantic check before
72
+ * bundling (maps to `rk build --no-typecheck`).
73
+ * Integration-test fixture builds set this so a pre-existing
74
+ * type error elsewhere in the workspace cannot block on-device
75
+ * verification of unrelated runtime APIs.
76
+ */
77
+ disableTypeChecking?: boolean;
78
+ }
79
+
80
+ /**
81
+ * Drive a `POST /api/build` SSE stream from the device-test-client
82
+ * to rk-server. Builds run in the sidecar container instead of the
83
+ * build container so esbuild service processes don't leak across
84
+ * Jest test-file module registries (each fresh `require` of
85
+ * `build-utils` would spawn a new `esbuild --service` child).
86
+ *
87
+ * Throws `RkClientError` on HTTP errors, SSE `error`/`complete`
88
+ * with `success:false`, or after the resolved idle timeout of
89
+ * inactivity — the caller wraps in `BuildFailedError`. The idle
90
+ * timeout caps sidecar wedges at a per-call failure so retries can
91
+ * recover instead of eating the whole CI job's wall-clock budget.
92
+ *
93
+ * The timeout is resolved from `RK_BUILD_SSE_IDLE_TIMEOUT_MS`
94
+ * (positive integer ms) at first call and cached for the process.
95
+ * A late env-var change does not retroactively reconfigure
96
+ * in-flight builds. Invalid values warn once and fall back to
97
+ * `BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS`.
98
+ */
99
+ let resolvedSseIdleTimeoutMs: number | undefined;
100
+
101
+ function resolveSseIdleTimeoutMs(): number {
102
+ if (resolvedSseIdleTimeoutMs !== undefined) return resolvedSseIdleTimeoutMs;
103
+ const raw = process.env[ENV_VAR.BUILD_SSE_IDLE_TIMEOUT_MS];
104
+ if (raw === undefined || raw === "") {
105
+ resolvedSseIdleTimeoutMs = BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS;
106
+ return resolvedSseIdleTimeoutMs;
107
+ }
108
+ const parsed = Number(raw);
109
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
110
+ // Warn once, then cache the fallback so we don't spam logs
111
+ // across every build call in a long suite.
112
+ console.warn(
113
+ `[device-test-client] ${ENV_VAR.BUILD_SSE_IDLE_TIMEOUT_MS}="${raw}" is not a positive integer; ` +
114
+ `falling back to default (${BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS}ms).`
115
+ );
116
+ resolvedSseIdleTimeoutMs = BUILD_SSE_IDLE_TIMEOUT_DEFAULT_MS;
117
+ return resolvedSseIdleTimeoutMs;
118
+ }
119
+ resolvedSseIdleTimeoutMs = parsed;
120
+ return resolvedSseIdleTimeoutMs;
121
+ }
122
+
123
+ /** Test-only: clear the cached idle-timeout so the next call re-reads env. */
124
+ export function __resetBuildSseIdleTimeoutForTests(): void {
125
+ resolvedSseIdleTimeoutMs = undefined;
126
+ }
127
+
128
+ export async function requestServerBuild(serverUrl: string, payload: ServerBuildRequest): Promise<void> {
129
+ const endpoint = `${trimTrailingSlash(serverUrl)}/build`;
130
+
131
+ const response = await fetch(endpoint, {
132
+ method: "POST",
133
+ headers: {
134
+ "Content-Type": "application/json",
135
+ Accept: "text/event-stream"
136
+ },
137
+ // Spread payload first; the explicit `buildType` override
138
+ // wins so the server sees a concrete value even if
139
+ // payload.buildType was blank.
140
+ body: JSON.stringify({
141
+ ...payload,
142
+ buildType: payload.buildType ?? "build"
143
+ })
144
+ });
145
+
146
+ if (!response.ok) {
147
+ // Body holds `{error, code}`; surface raw text and let the
148
+ // caller wrap in BuildFailedError with full path context.
149
+ const text = await response.text().catch(() => "<unreadable response body>");
150
+ throw new RkClientError(
151
+ `rk-server /api/build returned HTTP ${response.status}: ${text}`,
152
+ "RK_SERVER_BUILD_HTTP_ERROR"
153
+ );
154
+ }
155
+ if (!response.body) {
156
+ throw new RkClientError("rk-server /api/build returned no response body", "RK_SERVER_BUILD_EMPTY_BODY");
157
+ }
158
+
159
+ const reader = response.body.getReader();
160
+ const decoder = new TextDecoder();
161
+ let buffer = "";
162
+ let serverError: string | undefined;
163
+ let completed = false;
164
+ let success = false;
165
+
166
+ // SSE frames separated by `\n\n`. We only care about `error`
167
+ // and `complete` events; `stdout`/`stderr` build-log events
168
+ // are dropped (rk-server.log already captures them).
169
+ // Each `read()` races an idle timer; if no frame arrives for
170
+ // the resolved idle-timeout window we cancel the body and
171
+ // throw, converting sidecar wedges into recoverable per-call
172
+ // failures. Sized via env so cold-start CI runners can extend
173
+ // it without forcing a code change.
174
+ const idleTimeoutMs = resolveSseIdleTimeoutMs();
175
+ while (true) {
176
+ let result: ReadableStreamReadResult<Uint8Array>;
177
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
178
+ try {
179
+ result = await new Promise<ReadableStreamReadResult<Uint8Array>>((resolve, reject) => {
180
+ idleTimer = setTimeout(() => {
181
+ reader.cancel(new Error(`SSE idle timeout after ${idleTimeoutMs}ms`)).catch(() => {
182
+ // best-effort; we're rejecting either way
183
+ });
184
+ reject(
185
+ new RkClientError(
186
+ `rk-server /api/build SSE stream idle for ${idleTimeoutMs}ms ` +
187
+ `(override via ${ENV_VAR.BUILD_SSE_IDLE_TIMEOUT_MS})`,
188
+ "RK_SERVER_BUILD_IDLE_TIMEOUT"
189
+ )
190
+ );
191
+ }, idleTimeoutMs);
192
+ if (typeof (idleTimer as { unref?: () => unknown }).unref === "function") {
193
+ (idleTimer as { unref: () => unknown }).unref();
194
+ }
195
+ reader.read().then(resolve, reject);
196
+ });
197
+ } finally {
198
+ if (idleTimer !== undefined) clearTimeout(idleTimer);
199
+ }
200
+ const { value, done } = result;
201
+ if (done) break;
202
+ buffer += decoder.decode(value, { stream: true });
203
+
204
+ let separatorIndex = buffer.indexOf("\n\n");
205
+ while (separatorIndex >= 0) {
206
+ const frame = buffer.slice(0, separatorIndex);
207
+ buffer = buffer.slice(separatorIndex + 2);
208
+ separatorIndex = buffer.indexOf("\n\n");
209
+
210
+ let eventType: string | undefined;
211
+ let dataPayload = "";
212
+ for (const line of frame.split("\n")) {
213
+ const trimmed = line.trim();
214
+ if (!trimmed) continue;
215
+ if (trimmed.startsWith("event:")) {
216
+ eventType = trimmed.slice(6).trim();
217
+ } else if (trimmed.startsWith("data:")) {
218
+ const part = trimmed.slice(5).trim();
219
+ dataPayload = dataPayload ? `${dataPayload}${part}` : part;
220
+ }
221
+ }
222
+ if (!eventType) continue;
223
+
224
+ let data: unknown;
225
+ if (dataPayload) {
226
+ try {
227
+ data = JSON.parse(dataPayload);
228
+ } catch {
229
+ data = dataPayload;
230
+ }
231
+ }
232
+
233
+ if (eventType === "error") {
234
+ const message =
235
+ typeof data === "object" && data && "message" in data
236
+ ? String((data as { message?: unknown }).message ?? "")
237
+ : typeof data === "string"
238
+ ? data
239
+ : "rk-server build error (no message)";
240
+ serverError = message;
241
+ } else if (eventType === "complete") {
242
+ completed = true;
243
+ success =
244
+ typeof data === "object" && data && "success" in data
245
+ ? Boolean((data as { success?: boolean }).success)
246
+ : true;
247
+ }
248
+ }
249
+ }
250
+
251
+ if (serverError) {
252
+ throw new RkClientError(`rk-server build error: ${serverError}`, "RK_SERVER_BUILD_FAILED");
253
+ }
254
+ if (!completed) {
255
+ throw new RkClientError(
256
+ "rk-server build stream closed before emitting `complete`",
257
+ "RK_SERVER_BUILD_STREAM_TRUNCATED"
258
+ );
259
+ }
260
+ if (!success) {
261
+ throw new RkClientError(
262
+ "rk-server build emitted `complete` with success=false",
263
+ "RK_SERVER_BUILD_UNSUCCESSFUL"
264
+ );
265
+ }
266
+ }
267
+
268
+ /** Path and query of an ECP launch request, independent of any host. */
269
+ export function buildLaunchPath(channelId: string, deepLink?: Record<string, string>): string {
270
+ const base = `/launch/${encodeURIComponent(channelId)}`;
271
+ if (!deepLink || Object.keys(deepLink).length === 0) return base;
272
+ const params = new URLSearchParams();
273
+ for (const [k, v] of Object.entries(deepLink)) params.append(k, v);
274
+ return `${base}?${params.toString()}`;
275
+ }
276
+
277
+ export function buildLaunchUrl(ip: string, channelId: string, deepLink?: Record<string, string>): string {
278
+ return `http://${ip}:${ECP_PORT}${buildLaunchPath(channelId, deepLink)}`;
279
+ }
280
+
281
+ /**
282
+ * POST a no-body ECP request with the caller's timeout. Throws
283
+ * `ToolingError` with the standard tooling code taxonomy so retry
284
+ * predicates treat `/launch` and `/query` failures identically.
285
+ */
286
+ export async function postNoBody(url: string, timeoutMs: number): Promise<void> {
287
+ const controller = new AbortController();
288
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
289
+ if (typeof (timer as { unref?: () => unknown }).unref === "function") {
290
+ (timer as { unref: () => unknown }).unref();
291
+ }
292
+ let res: Response;
293
+ try {
294
+ res = await fetch(url, {
295
+ method: "POST",
296
+ headers: { "Content-Length": "0" },
297
+ signal: controller.signal
298
+ });
299
+ } catch (err) {
300
+ clearTimeout(timer);
301
+ if ((err as Error).name === "AbortError") {
302
+ throw new ToolingError(`Request to ${url} timed out after ${timeoutMs}ms.`, "ECP_TIMEOUT");
303
+ }
304
+ throw new ToolingError(`ECP launch failed: ${(err as Error).message}`, "ECP_UNKNOWN_ERROR");
305
+ }
306
+ clearTimeout(timer);
307
+ if (!res.ok) {
308
+ // Mirrors EcpClient.handleHttpError so isTransientEcpError
309
+ // detects 5xx uniformly.
310
+ if (res.status === 401) {
311
+ throw new ToolingError(`Developer mode disabled on device (${url}).`, "ECP_DEVELOPER_MODE_DISABLED");
312
+ }
313
+ if (res.status === 404) {
314
+ throw new ToolingError(`ECP endpoint not found: ${url}.`, "ECP_ENDPOINT_NOT_FOUND");
315
+ }
316
+ throw new ToolingError(`ECP launch failed: ${res.status} ${res.statusText}`, "ECP_REQUEST_FAILED");
317
+ }
318
+ }
319
+
320
+ export function directoryExists(p: string): boolean {
321
+ try {
322
+ return statSync(p).isDirectory();
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Resolve `appPath`: absolute paths verbatim; relative paths
330
+ * tried against cwd, then the nearest `pnpm-workspace.yaml`
331
+ * ancestor. Falls back to the cwd-relative path so error
332
+ * messages name what the user most likely intended.
333
+ */
334
+ export function resolveAppPath(appPath: string): string {
335
+ const cwdResolved = resolvePath(process.cwd(), appPath);
336
+ if (directoryExists(cwdResolved)) return cwdResolved;
337
+
338
+ const workspaceRoot = findWorkspaceRoot(process.cwd());
339
+ if (workspaceRoot) {
340
+ const workspaceResolved = resolvePath(workspaceRoot, appPath);
341
+ if (directoryExists(workspaceResolved)) return workspaceResolved;
342
+ }
343
+ return cwdResolved;
344
+ }
345
+
346
+ export function findWorkspaceRoot(startDir: string): string | null {
347
+ let dir = startDir;
348
+ while (true) {
349
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
350
+ const parent = resolvePath(dir, "..");
351
+ if (parent === dir) return null;
352
+ dir = parent;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Read sideload app-type from a built pkg's `manifest`.
358
+ * Presence of `sg_component_libs_provided` → DCL library
359
+ * (`InstallLib_<name>`); otherwise a standard channel.
360
+ */
361
+ export async function readAppTypeFromManifest(
362
+ pkgDir: string,
363
+ readManifestEntries: ToolingDeps["readManifestEntries"]
364
+ ): Promise<{ appType: "" | "channel" | "dcl"; libraryName?: string }> {
365
+ try {
366
+ const entries = await readManifestEntries(join(pkgDir, "manifest"));
367
+ const libsProvided = entries["sg_component_libs_provided"];
368
+ if (libsProvided) {
369
+ return { appType: "dcl", libraryName: libsProvided };
370
+ }
371
+ } catch {
372
+ // No manifest / unreadable — fall through to plain channel install.
373
+ }
374
+ return { appType: "" };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------
378
+ // Tooling dep surface — re-exported from `@roku-sdk/tooling`.
379
+ //
380
+ // The shapes and the lazy `require()` loader live on the tooling side
381
+ // (see `@roku-sdk/tooling/device-test-helpers`) so the deep-require
382
+ // paths are owned by the package whose internals they reference. We
383
+ // re-export them under the original local names so the rest of this
384
+ // package's source stays diff-free; consumers outside the package
385
+ // continue to import from `@roku-sdk/device-test-client`.
386
+ //
387
+ // Why re-export instead of directly importing from the helper at
388
+ // every callsite: `client.ts` already has `import type { ... } from
389
+ // "./deploy"` for these names, and changing every callsite would
390
+ // churn unrelated files for no readability win.
391
+ // ---------------------------------------------------------------------
392
+
393
+ export type ToolingDevice = ToolingDeviceFromHelpers;
394
+ export type RokuDeviceInstance = ToolingRokuDeviceInstance;
395
+ export type ToolingDeps = ToolingDepsFromHelpers;
396
+
397
+ /**
398
+ * Load the heavy build-orchestration surface from `@roku-sdk/tooling`.
399
+ * Thin pass-through to `loadToolingDeps()` in
400
+ * `@roku-sdk/tooling/device-test-helpers`; kept as a local export so
401
+ * existing `import { loadToolingDeps } from "./deploy"` callsites
402
+ * (and the package's unit-test mocks targeting the underlying
403
+ * tooling modules via `jest.mock(...)`) keep their existing shape.
404
+ */
405
+ export function loadToolingDeps(): ToolingDeps {
406
+ return loadToolingDepsFromHelpers();
407
+ }
@@ -0,0 +1,112 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Serialized request queue with per-category minimum gap enforcement.
5
+ *
6
+ * Rationale: Roku's ECP server is single-threaded on the device side.
7
+ * Overlapping requests cause dropped calls and intermittent failures.
8
+ * All framework traffic therefore funnels through one queue with
9
+ * `concurrency = 1`. Additional throttles (200ms for `/query/app-ui`,
10
+ * 500ms for `/capture-screen`) layer on top per-category.
11
+ *
12
+ * The interval is measured between the **end** of the previous call in
13
+ * the same category and the **start** of the next — "gap-based", not
14
+ * "start-to-start". This matches the "minimum interval" semantics and
15
+ * is what the acceptance criteria test (`3x queryAppUi in >= 400ms` →
16
+ * 200ms gap × 2).
17
+ *
18
+ * Not exported from `src/index.ts`; it is an internal primitive used by
19
+ * `RkClient`. Exposed in tests via a dedicated relative import.
20
+ */
21
+
22
+ type QueueTask<T = unknown> = {
23
+ category: string;
24
+ run: () => Promise<T>;
25
+ resolve: (value: T) => void;
26
+ reject: (reason: unknown) => void;
27
+ };
28
+
29
+ export class EcpRequestQueue {
30
+ private readonly minIntervalsByCategory: Record<string, number>;
31
+ private readonly lastFinishedAt: Record<string, number> = {};
32
+ private readonly pending: QueueTask[] = [];
33
+ private running = false;
34
+
35
+ /**
36
+ * @param minIntervalsByCategory - Minimum ms gap per category. Unknown
37
+ * categories passed to `enqueue` are treated as `0` (serial but
38
+ * unthrottled).
39
+ */
40
+ constructor(minIntervalsByCategory: Record<string, number>) {
41
+ this.minIntervalsByCategory = { ...minIntervalsByCategory };
42
+ }
43
+
44
+ /**
45
+ * Enqueue `fn` under `category`. The returned promise resolves with
46
+ * `fn`'s value (or rejects with its error) after:
47
+ * 1. all previously-enqueued tasks have settled (FIFO), and
48
+ * 2. the per-category minimum gap since the last successful call
49
+ * in that category has elapsed.
50
+ *
51
+ * If `fn` throws, the next queued task runs immediately — a failure
52
+ * does NOT consume the rate-limit budget of its category. This keeps
53
+ * a broken endpoint from stalling the suite with phantom waits.
54
+ */
55
+ enqueue<T>(category: string, fn: () => Promise<T>): Promise<T> {
56
+ return new Promise<T>((resolve, reject) => {
57
+ this.pending.push({
58
+ category,
59
+ run: fn as () => Promise<unknown>,
60
+ resolve: resolve as (v: unknown) => void,
61
+ reject
62
+ });
63
+ void this.drain();
64
+ });
65
+ }
66
+
67
+ private async drain(): Promise<void> {
68
+ if (this.running) return;
69
+ this.running = true;
70
+
71
+ try {
72
+ while (this.pending.length > 0) {
73
+ const task = this.pending.shift()!;
74
+ const minInterval = this.minIntervalsByCategory[task.category] ?? 0;
75
+ const lastEnd = this.lastFinishedAt[task.category] ?? 0;
76
+
77
+ if (minInterval > 0) {
78
+ const wait = lastEnd + minInterval - Date.now();
79
+ if (wait > 0) {
80
+ await sleepUnref(wait);
81
+ }
82
+ }
83
+
84
+ try {
85
+ const result = await task.run();
86
+ // Only successful calls count toward the rate limit;
87
+ // failed endpoints must not stall the queue.
88
+ this.lastFinishedAt[task.category] = Date.now();
89
+ task.resolve(result);
90
+ } catch (err) {
91
+ task.reject(err);
92
+ }
93
+ }
94
+ } finally {
95
+ this.running = false;
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `setTimeout`-based sleep that `unref()`s the timer so Node can exit
102
+ * cleanly if the queue is drained while a timeout is still armed.
103
+ */
104
+ function sleepUnref(ms: number): Promise<void> {
105
+ return new Promise<void>(resolve => {
106
+ const t = setTimeout(resolve, ms);
107
+ // `.unref()` is only defined in Node; guard for non-Node hosts.
108
+ if (typeof (t as { unref?: () => unknown }).unref === "function") {
109
+ (t as { unref: () => unknown }).unref();
110
+ }
111
+ });
112
+ }
@@ -0,0 +1,94 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Single-shot retry wrapper for transient ECP failures.
5
+ *
6
+ * Per Jira AC: "On 5xx or timeout, retry once after 500ms backoff,
7
+ * transparent to callers." Not exponential, not configurable count —
8
+ * exactly one retry. If the retry fails, we surface the second error,
9
+ * so the caller gets the most recent diagnostic.
10
+ */
11
+
12
+ import { ToolingError } from "@roku-sdk/tooling/src/device-test-helpers";
13
+
14
+ export interface RetryOptions {
15
+ /** Delay between the first failure and the retry (ms). */
16
+ backoffMs: number;
17
+ /** Predicate — return true to retry, false to rethrow immediately. */
18
+ isTransient: (err: unknown) => boolean;
19
+ }
20
+
21
+ /**
22
+ * Run `op`. On failure, if `isTransient(err)` is true, wait `backoffMs`
23
+ * and run once more. Propagates the second error (not the first) on
24
+ * double-failure so the most recent device signal reaches the caller.
25
+ */
26
+ export async function retryOnTransient<T>(op: () => Promise<T>, opts: RetryOptions): Promise<T> {
27
+ try {
28
+ return await op();
29
+ } catch (err) {
30
+ if (!opts.isTransient(err)) throw err;
31
+ await sleepUnref(opts.backoffMs);
32
+ return await op();
33
+ }
34
+ }
35
+
36
+ /**
37
+ * ECP-specific transience predicate. Treats these as retryable:
38
+ * - `ECP_TIMEOUT` — fetch aborted by our `AbortController`.
39
+ * - `ECP_REQUEST_FAILED` with status 5xx parsed out of the message.
40
+ * - `ECP_UNKNOWN_ERROR` when the underlying fetch surfaced a generic
41
+ * transport failure (`fetch failed`), which we observe on the room
42
+ * Roku as a transient blip even while the device remains healthy.
43
+ *
44
+ * Non-transient (intentionally):
45
+ * - 401 `ECP_DEVELOPER_MODE_DISABLED` — config issue, retrying can't help.
46
+ * - 404 `ECP_ENDPOINT_NOT_FOUND` — wrong endpoint / unsupported device.
47
+ * - Invalid-IP / connection-refused — physical issue, retry is noise.
48
+ *
49
+ * The 5xx branch delegates to `isEcpServerError`, which parses the
50
+ * status out of the message; on parse failure it reports false, so we
51
+ * default to NOT retrying — false negatives are safer than retrying
52
+ * 4xx forever.
53
+ */
54
+ export function isTransientEcpError(err: unknown): boolean {
55
+ if (!(err instanceof ToolingError)) return false;
56
+
57
+ if (err.code === "ECP_TIMEOUT") return true;
58
+
59
+ if (err.code === "ECP_REQUEST_FAILED") return isEcpServerError(err);
60
+
61
+ if (err.code === "ECP_UNKNOWN_ERROR") {
62
+ return err.message.includes("fetch failed");
63
+ }
64
+
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * True when `err` is an `ECP_REQUEST_FAILED` carrying a 5xx status —
70
+ * the device answered, but its own subsystem refused the request
71
+ * (e.g. `503` from a launcher that is busy foregrounding a channel).
72
+ * Callers that can verify the outcome another way use this to tell a
73
+ * device-side hiccup apart from a client-side mistake (4xx).
74
+ *
75
+ * `ToolingError` bakes the status into the message as
76
+ * `ECP ${context} failed: ${status} ${statusText}`, which is what the
77
+ * status is parsed from.
78
+ */
79
+ export function isEcpServerError(err: unknown): boolean {
80
+ if (!(err instanceof ToolingError) || err.code !== "ECP_REQUEST_FAILED") return false;
81
+ const match = /failed:\s*(\d{3})\b/.exec(err.message);
82
+ if (!match) return false;
83
+ const status = Number(match[1]);
84
+ return status >= 500 && status < 600;
85
+ }
86
+
87
+ function sleepUnref(ms: number): Promise<void> {
88
+ return new Promise<void>(resolve => {
89
+ const t = setTimeout(resolve, ms);
90
+ if (typeof (t as { unref?: () => unknown }).unref === "function") {
91
+ (t as { unref: () => unknown }).unref();
92
+ }
93
+ });
94
+ }