@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.
- 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 +525 -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 +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -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 +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -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 +773 -0
- package/src/index.ts +186 -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 +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -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 +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +443 -0
|
@@ -0,0 +1,402 @@
|
|
|
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
|
+
export function buildLaunchUrl(ip: string, channelId: string, deepLink?: Record<string, string>): string {
|
|
269
|
+
const base = `http://${ip}:${ECP_PORT}/launch/${encodeURIComponent(channelId)}`;
|
|
270
|
+
if (!deepLink || Object.keys(deepLink).length === 0) return base;
|
|
271
|
+
const params = new URLSearchParams();
|
|
272
|
+
for (const [k, v] of Object.entries(deepLink)) params.append(k, v);
|
|
273
|
+
return `${base}?${params.toString()}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* POST a no-body ECP request with the caller's timeout. Throws
|
|
278
|
+
* `ToolingError` with the standard tooling code taxonomy so retry
|
|
279
|
+
* predicates treat `/launch` and `/query` failures identically.
|
|
280
|
+
*/
|
|
281
|
+
export async function postNoBody(url: string, timeoutMs: number): Promise<void> {
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
284
|
+
if (typeof (timer as { unref?: () => unknown }).unref === "function") {
|
|
285
|
+
(timer as { unref: () => unknown }).unref();
|
|
286
|
+
}
|
|
287
|
+
let res: Response;
|
|
288
|
+
try {
|
|
289
|
+
res = await fetch(url, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Length": "0" },
|
|
292
|
+
signal: controller.signal
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
if ((err as Error).name === "AbortError") {
|
|
297
|
+
throw new ToolingError(`Request to ${url} timed out after ${timeoutMs}ms.`, "ECP_TIMEOUT");
|
|
298
|
+
}
|
|
299
|
+
throw new ToolingError(`ECP launch failed: ${(err as Error).message}`, "ECP_UNKNOWN_ERROR");
|
|
300
|
+
}
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
// Mirrors EcpClient.handleHttpError so isTransientEcpError
|
|
304
|
+
// detects 5xx uniformly.
|
|
305
|
+
if (res.status === 401) {
|
|
306
|
+
throw new ToolingError(`Developer mode disabled on device (${url}).`, "ECP_DEVELOPER_MODE_DISABLED");
|
|
307
|
+
}
|
|
308
|
+
if (res.status === 404) {
|
|
309
|
+
throw new ToolingError(`ECP endpoint not found: ${url}.`, "ECP_ENDPOINT_NOT_FOUND");
|
|
310
|
+
}
|
|
311
|
+
throw new ToolingError(`ECP launch failed: ${res.status} ${res.statusText}`, "ECP_REQUEST_FAILED");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function directoryExists(p: string): boolean {
|
|
316
|
+
try {
|
|
317
|
+
return statSync(p).isDirectory();
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolve `appPath`: absolute paths verbatim; relative paths
|
|
325
|
+
* tried against cwd, then the nearest `pnpm-workspace.yaml`
|
|
326
|
+
* ancestor. Falls back to the cwd-relative path so error
|
|
327
|
+
* messages name what the user most likely intended.
|
|
328
|
+
*/
|
|
329
|
+
export function resolveAppPath(appPath: string): string {
|
|
330
|
+
const cwdResolved = resolvePath(process.cwd(), appPath);
|
|
331
|
+
if (directoryExists(cwdResolved)) return cwdResolved;
|
|
332
|
+
|
|
333
|
+
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
|
334
|
+
if (workspaceRoot) {
|
|
335
|
+
const workspaceResolved = resolvePath(workspaceRoot, appPath);
|
|
336
|
+
if (directoryExists(workspaceResolved)) return workspaceResolved;
|
|
337
|
+
}
|
|
338
|
+
return cwdResolved;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function findWorkspaceRoot(startDir: string): string | null {
|
|
342
|
+
let dir = startDir;
|
|
343
|
+
while (true) {
|
|
344
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
345
|
+
const parent = resolvePath(dir, "..");
|
|
346
|
+
if (parent === dir) return null;
|
|
347
|
+
dir = parent;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Read sideload app-type from a built pkg's `manifest`.
|
|
353
|
+
* Presence of `sg_component_libs_provided` → DCL library
|
|
354
|
+
* (`InstallLib_<name>`); otherwise a standard channel.
|
|
355
|
+
*/
|
|
356
|
+
export async function readAppTypeFromManifest(
|
|
357
|
+
pkgDir: string,
|
|
358
|
+
readManifestEntries: ToolingDeps["readManifestEntries"]
|
|
359
|
+
): Promise<{ appType: "" | "channel" | "dcl"; libraryName?: string }> {
|
|
360
|
+
try {
|
|
361
|
+
const entries = await readManifestEntries(join(pkgDir, "manifest"));
|
|
362
|
+
const libsProvided = entries["sg_component_libs_provided"];
|
|
363
|
+
if (libsProvided) {
|
|
364
|
+
return { appType: "dcl", libraryName: libsProvided };
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// No manifest / unreadable — fall through to plain channel install.
|
|
368
|
+
}
|
|
369
|
+
return { appType: "" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------
|
|
373
|
+
// Tooling dep surface — re-exported from `@roku-sdk/tooling`.
|
|
374
|
+
//
|
|
375
|
+
// The shapes and the lazy `require()` loader live on the tooling side
|
|
376
|
+
// (see `@roku-sdk/tooling/device-test-helpers`) so the deep-require
|
|
377
|
+
// paths are owned by the package whose internals they reference. We
|
|
378
|
+
// re-export them under the original local names so the rest of this
|
|
379
|
+
// package's source stays diff-free; consumers outside the package
|
|
380
|
+
// continue to import from `@roku-sdk/device-test-client`.
|
|
381
|
+
//
|
|
382
|
+
// Why re-export instead of directly importing from the helper at
|
|
383
|
+
// every callsite: `client.ts` already has `import type { ... } from
|
|
384
|
+
// "./deploy"` for these names, and changing every callsite would
|
|
385
|
+
// churn unrelated files for no readability win.
|
|
386
|
+
// ---------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
export type ToolingDevice = ToolingDeviceFromHelpers;
|
|
389
|
+
export type RokuDeviceInstance = ToolingRokuDeviceInstance;
|
|
390
|
+
export type ToolingDeps = ToolingDepsFromHelpers;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Load the heavy build-orchestration surface from `@roku-sdk/tooling`.
|
|
394
|
+
* Thin pass-through to `loadToolingDeps()` in
|
|
395
|
+
* `@roku-sdk/tooling/device-test-helpers`; kept as a local export so
|
|
396
|
+
* existing `import { loadToolingDeps } from "./deploy"` callsites
|
|
397
|
+
* (and the package's unit-test mocks targeting the underlying
|
|
398
|
+
* tooling modules via `jest.mock(...)`) keep their existing shape.
|
|
399
|
+
*/
|
|
400
|
+
export function loadToolingDeps(): ToolingDeps {
|
|
401
|
+
return loadToolingDepsFromHelpers();
|
|
402
|
+
}
|
|
@@ -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
|
+
}
|