@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
package/docs/rkclient.md
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# `RkClient` reference
|
|
2
|
+
|
|
3
|
+
`RkClient` is the single entry point for ECP communication with a Roku
|
|
4
|
+
device. It serialises every request through a queue with per-category
|
|
5
|
+
minimum intervals, retries transient 5xx / timeout failures exactly
|
|
6
|
+
once, and layers readiness / health / polling / log semantics on top of
|
|
7
|
+
the raw ECP surface.
|
|
8
|
+
|
|
9
|
+
- [Construction](#construction)
|
|
10
|
+
- [ECP queries](#ecp-queries)
|
|
11
|
+
- [Input](#input)
|
|
12
|
+
- [Lifecycle (launch / sideload / deep-link)](#lifecycle)
|
|
13
|
+
- [Readiness & health](#readiness--health)
|
|
14
|
+
- [Polling helpers](#polling-helpers)
|
|
15
|
+
- [Log access](#log-access)
|
|
16
|
+
- [Parsers](#parsers)
|
|
17
|
+
- [Rate limits & retries](#rate-limits--retries)
|
|
18
|
+
- [Errors thrown](#errors-thrown)
|
|
19
|
+
|
|
20
|
+
## Construction
|
|
21
|
+
|
|
22
|
+
### `RkClient`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
class RkClient {
|
|
26
|
+
readonly deviceIp: string;
|
|
27
|
+
readonly keyDelay: number;
|
|
28
|
+
|
|
29
|
+
constructor(opts: RkClientOptions);
|
|
30
|
+
|
|
31
|
+
getServerUrl(): string;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface RkClientOptions {
|
|
37
|
+
/** IPv4 of the target device. REQUIRED for `new RkClient`. */
|
|
38
|
+
deviceIp?: string;
|
|
39
|
+
/** Inter-key delay for `keysequence` (ms). Default `100`. */
|
|
40
|
+
keyDelay?: number;
|
|
41
|
+
/** Per-request ECP timeout in ms (captures use their own longer timeout). Default `5000`. */
|
|
42
|
+
requestTimeoutMs?: number;
|
|
43
|
+
/** Override per-category minimum queue intervals (ms). For tests only. */
|
|
44
|
+
intervals?: Partial<Record<EcpCategory, number>>;
|
|
45
|
+
/** `rk server` base URL. Resolution: opts → `RK_SERVER_URL` → `.roku/rk-server.json` → `http://localhost:3737/api`. Normalized to end with `/api` (no trailing slash) regardless of source. */
|
|
46
|
+
serverUrl?: string;
|
|
47
|
+
/** Pre-built `LogSessionLike` — reserved for tests, ignored by normal callers. */
|
|
48
|
+
logSession?: LogSessionLike;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type EcpCategory =
|
|
52
|
+
| "query-appui"
|
|
53
|
+
| "query-device-info"
|
|
54
|
+
| "query-active-app"
|
|
55
|
+
| "keypress"
|
|
56
|
+
| "launch"
|
|
57
|
+
| "capture";
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Example — direct construction.**
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { RkClient } from "@roku-sdk/device-test-client";
|
|
64
|
+
|
|
65
|
+
const client = new RkClient({
|
|
66
|
+
deviceIp: "192.168.1.42",
|
|
67
|
+
keyDelay: 150
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Call `new RkClient({})` without `deviceIp` to see `RkClientError(RK_DEVICE_IP_UNRESOLVED)`
|
|
72
|
+
— the error message tells you to use `createDefaultClient()` instead.
|
|
73
|
+
|
|
74
|
+
### `createDefaultClient`
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
function createDefaultClient(opts?: RkClientOptions): RkClient;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Single source of truth for env-driven construction. Resolves `deviceIp`
|
|
81
|
+
from `opts.deviceIp` → `RK_DEVICE_IP` → `test-results/.device-ip`
|
|
82
|
+
(written by `globalSetup`). If `test-results/device-env.json` already
|
|
83
|
+
exists, the cache is seeded so `client.cachedDeviceInfo()` returns
|
|
84
|
+
immediately.
|
|
85
|
+
|
|
86
|
+
**Example — worker-side wiring.**
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { createDefaultClient } from "@roku-sdk/device-test-client";
|
|
90
|
+
|
|
91
|
+
const client = createDefaultClient(); // Uses globalSetup-resolved IP.
|
|
92
|
+
const client2 = createDefaultClient({ keyDelay: 200 });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Common pitfall.** `createDefaultClient` is synchronous. If you construct
|
|
96
|
+
it from a host-side script that has not yet run `globalSetup` and has no
|
|
97
|
+
`RK_DEVICE_IP`, the call throws. In `globalSetup` itself, pass
|
|
98
|
+
`deviceIp` explicitly (see `createGlobalSetup` in
|
|
99
|
+
`[jest-integration.md](jest-integration.md)`).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## ECP queries
|
|
104
|
+
|
|
105
|
+
### `queryAppUi`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
queryAppUi(): Promise<AppUiNode>;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
interface AppUiNode {
|
|
113
|
+
tag: string;
|
|
114
|
+
attrs: Record<string, string>;
|
|
115
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
116
|
+
focused: boolean;
|
|
117
|
+
children: AppUiNode[];
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Fetches the full SceneGraph tree via `/query/app-ui`. Rate-limited at
|
|
122
|
+
200 ms between successful calls. The ECP `name=` attribute is mirrored
|
|
123
|
+
into `attrs.id` at parse time so predicates can use the canonical `id`
|
|
124
|
+
selector (see [DOM assertions](dom-assertions.md#id--name-alias)).
|
|
125
|
+
|
|
126
|
+
**Example.**
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const tree = await client.queryAppUi();
|
|
130
|
+
console.log(tree.tag); // typically "app-ui"
|
|
131
|
+
console.log(tree.children.length);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `queryDeviceInfo` / `cachedDeviceInfo` / `seedDeviceInfo`
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
queryDeviceInfo(): Promise<DeviceInfo>;
|
|
138
|
+
cachedDeviceInfo(): DeviceInfo | null;
|
|
139
|
+
seedDeviceInfo(info: DeviceInfo): void;
|
|
140
|
+
resetCache(): void;
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface DeviceInfo {
|
|
145
|
+
modelName: string;
|
|
146
|
+
modelNumber: string;
|
|
147
|
+
modelFamily: string;
|
|
148
|
+
deviceClass: string; // form-factor: "tv" | "stick" | "player"
|
|
149
|
+
firmwareVersion: string;
|
|
150
|
+
videoMode: string; // e.g. "1080p60"
|
|
151
|
+
locale: string;
|
|
152
|
+
country: string;
|
|
153
|
+
libraries: Record<string, string>;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`queryDeviceInfo` always hits the wire (ECP `/query/device-info`) and
|
|
158
|
+
caches the result. `cachedDeviceInfo` is free and returns `null` before
|
|
159
|
+
the first query. `seedDeviceInfo` primes the cache from an external
|
|
160
|
+
source (what `createDefaultClient` does with `device-env.json`).
|
|
161
|
+
|
|
162
|
+
**Example — cache-aware read.**
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const info = client.cachedDeviceInfo() ?? (await client.queryDeviceInfo());
|
|
166
|
+
if (info.videoMode.startsWith("1080p")) {
|
|
167
|
+
// use full-HD baselines
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### `queryActiveApp`
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
queryActiveApp(): Promise<ActiveAppInfo>;
|
|
175
|
+
|
|
176
|
+
interface ActiveAppInfo {
|
|
177
|
+
id: string; // "dev" for sideloaded, channel id for published, "" on home
|
|
178
|
+
title: string; // "Roku" on home
|
|
179
|
+
type: string; // e.g. "appl"
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Example.**
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const active = await client.queryActiveApp();
|
|
187
|
+
expect(active.id).toBe("dev");
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `captureScreenshot`
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
captureScreenshot(): Promise<Buffer>;
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Raw PNG bytes from `/capture-screen/png`. Rate-limited at 500 ms between
|
|
197
|
+
captures. For meaningful pixel comparisons, use
|
|
198
|
+
`[captureStableScreenshot](#capturestablescreenshot)` which polls until
|
|
199
|
+
two successive frames are identical.
|
|
200
|
+
|
|
201
|
+
**Example.**
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { writeFileSync } from "node:fs";
|
|
205
|
+
writeFileSync("debug.png", await client.captureScreenshot());
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Input
|
|
211
|
+
|
|
212
|
+
### `keypress`
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
keypress(key: string): Promise<void>;
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Sends exactly one ECP keypress. The key name is validated before the
|
|
219
|
+
queue is entered — typos throw `RkClientError(ECP_INVALID_KEY)`
|
|
220
|
+
immediately. Accepts any Roku key (`"Up"`, `"Down"`, `"Select"`,
|
|
221
|
+
`"Home"`, `"Back"`, etc.).
|
|
222
|
+
|
|
223
|
+
**Example.**
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await client.keypress("Select");
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### `keysequence`
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
keysequence(keys: string[], opts?: KeySequenceOptions): Promise<void>;
|
|
233
|
+
|
|
234
|
+
interface KeySequenceOptions {
|
|
235
|
+
/** Inter-key delay (ms) between the end of one keypress and the start of the next. Defaults to `client.keyDelay`. */
|
|
236
|
+
keyDelay?: number;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Validates every key up-front so a typo at index 7 fails before index 0
|
|
241
|
+
goes out. The delay is applied between keys — not before the first or
|
|
242
|
+
after the last.
|
|
243
|
+
|
|
244
|
+
**Example.**
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
await client.keysequence(["Down", "Down", "Right", "Select"]);
|
|
248
|
+
await client.keysequence(["Up", "Up"], { keyDelay: 250 });
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Lifecycle
|
|
254
|
+
|
|
255
|
+
### `launch`
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
launch(opts?: LaunchOptions): Promise<void>;
|
|
259
|
+
|
|
260
|
+
interface LaunchOptions {
|
|
261
|
+
/** ECP channel id. Defaults to `"dev"`. */
|
|
262
|
+
channelId?: string;
|
|
263
|
+
/** Deep-link params for an **already-running** channel (reliable path). */
|
|
264
|
+
deepLink?: Record<string, string>;
|
|
265
|
+
/** Readiness gate timeout (ms). Defaults to 10_000. */
|
|
266
|
+
readinessTimeout?: number;
|
|
267
|
+
/** Semantic readiness predicate (see `BuildAndDeployOptions.readyNode`). */
|
|
268
|
+
readyNode?: NodePredicate;
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Launches a channel via `/launch/{channelId}`, then blocks on the shared
|
|
273
|
+
readiness + dialog gate. `launch` is the **reliable** path for
|
|
274
|
+
deep-link delivery when the channel is already installed — use it from
|
|
275
|
+
`beforeEach` to return the app to a known state between tests. For
|
|
276
|
+
cold sideloads that must land deep-linked, use
|
|
277
|
+
`[buildAndLaunchWithParams](#buildandlaunchwithparams)`.
|
|
278
|
+
|
|
279
|
+
**Example — re-open to a specific state.**
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
beforeEach(async () => {
|
|
283
|
+
await client.launch({
|
|
284
|
+
deepLink: { contentId: "home" },
|
|
285
|
+
readyNode: { attr: { id: "home-root" } }
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Auto-launch tolerance.** For a plain `dev` launch (no `deepLink`) the
|
|
291
|
+
POST races the launch the firmware already started for the sideloaded
|
|
292
|
+
build, so its verdict is not authoritative: a timeout, a `fetch failed`
|
|
293
|
+
blip, a 5xx from the busy launcher (`503 Service Unavailable` on
|
|
294
|
+
virtualized hosts), or a 404 from firmware that does not serve `/launch/dev` at all
|
|
295
|
+
are all tolerated, and the readiness gate decides. Any other `launch`
|
|
296
|
+
call — a named `channelId`, or one carrying `deepLink` params — treats
|
|
297
|
+
the POST status as final, because nothing else can prove those params
|
|
298
|
+
were delivered. `[buildAndLaunchWithParams](#buildandlaunchwithparams)`
|
|
299
|
+
is the one exception: its params POST tolerates the same race errors
|
|
300
|
+
(not a 404) because its mandatory params-ack log proves delivery.
|
|
301
|
+
|
|
302
|
+
### `buildAndDeploy`
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
buildAndDeploy(appPath: string, opts?: BuildAndDeployOptions): Promise<void>;
|
|
306
|
+
|
|
307
|
+
interface BuildAndDeployOptions {
|
|
308
|
+
deepLink?: Record<string, string>; // see "Common pitfall" below
|
|
309
|
+
readinessTimeout?: number; // ms, default 10_000
|
|
310
|
+
channelId?: string; // default "dev"
|
|
311
|
+
deviceSelector?: string; // override .roku/ active device
|
|
312
|
+
readyNode?: NodePredicate; // semantic readiness gate
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Builds the app at `appPath` (expects a `roku-config.ts` next to it),
|
|
317
|
+
sideloads the pkg-dir, launches, and blocks until the app is
|
|
318
|
+
interactive. Host-side build failures throw `BuildFailedError` (the
|
|
319
|
+
device is not marked unhealthy — the next test may succeed after a
|
|
320
|
+
code fix). Device-side failures throw `DeployFailedError` and flip the
|
|
321
|
+
health flag so `beforeEach` in the next test aborts fast.
|
|
322
|
+
|
|
323
|
+
**Minimal example.**
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
await client.buildAndDeploy("apps/smoke");
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Realistic example.**
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
await client.buildAndDeploy("apps/grid", {
|
|
333
|
+
readyNode: { attr: { id: "grid-title", text: "Featured" } },
|
|
334
|
+
readinessTimeout: 20_000
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Common pitfall — cold-sideload deep-link race.** `buildAndDeploy`'s
|
|
339
|
+
`deepLink` option is best-effort: on a fresh sideload the Install ECP
|
|
340
|
+
endpoint auto-launches without params, and the follow-up launch-with-
|
|
341
|
+
params POST frequently arrives before the app's
|
|
342
|
+
`setLaunchParamsUpdateHandler` has registered. Firmware drops it
|
|
343
|
+
silently. Use `buildAndLaunchWithParams` below when correctness
|
|
344
|
+
depends on a specific deep-linked landing state.
|
|
345
|
+
|
|
346
|
+
### `buildAndLaunchWithParams`
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
buildAndLaunchWithParams(
|
|
350
|
+
appPath: string,
|
|
351
|
+
deepLink: Record<string, string>,
|
|
352
|
+
opts: BuildAndLaunchWithParamsOptions
|
|
353
|
+
): Promise<void>;
|
|
354
|
+
|
|
355
|
+
interface BuildAndLaunchWithParamsOptions {
|
|
356
|
+
deviceSelector?: string;
|
|
357
|
+
channelId?: string;
|
|
358
|
+
readinessTimeout?: number;
|
|
359
|
+
readyNode?: NodePredicate;
|
|
360
|
+
/** Required. Regex the app emits once its launch-params handler is live. */
|
|
361
|
+
runtimeReadyLogPattern: RegExp;
|
|
362
|
+
runtimeReadyTimeout?: number; // ms, default 5_000
|
|
363
|
+
/** Required. Regex the app emits once params propagate through the reactive graph. */
|
|
364
|
+
paramsAckLogPattern: RegExp;
|
|
365
|
+
paramsAckTimeout?: number; // ms, default 5_000
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Implements the three-step dance the cold-sideload race requires:
|
|
370
|
+
|
|
371
|
+
1. sideload + auto-launch without params,
|
|
372
|
+
2. `waitForLog(runtimeReadyLogPattern)` — proves the handler is live,
|
|
373
|
+
3. `/launch/{channelId}?…` with params + `waitForLog(paramsAckLogPattern)` —
|
|
374
|
+
proves the reactive graph observed them.
|
|
375
|
+
|
|
376
|
+
Both log patterns are **required** — type-system enforcement that the
|
|
377
|
+
caller thought about observability.
|
|
378
|
+
|
|
379
|
+
**Example.**
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
await client.buildAndLaunchWithParams(
|
|
383
|
+
"apps/player",
|
|
384
|
+
{ contentId: "abc-123", startPos: "45" },
|
|
385
|
+
{
|
|
386
|
+
readyNode: { attr: { id: "player-ready" } },
|
|
387
|
+
runtimeReadyLogPattern: /launch-params-handler: ready/,
|
|
388
|
+
paramsAckLogPattern: /launch-params: contentId=abc-123/
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Readiness & health
|
|
396
|
+
|
|
397
|
+
### `assertAppAlive`
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
assertAppAlive(context?: unknown): Promise<void>;
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Single-shot "is the app still on screen?" probe. Throws
|
|
404
|
+
`AppCrashedError` if `/query/active-app` reports the home screen. Every
|
|
405
|
+
polling helper in the SDK (`waitForNode`, `waitForLog`,
|
|
406
|
+
`captureStableScreenshot`) invokes this between iterations so crashes
|
|
407
|
+
surface immediately instead of consuming the full timeout.
|
|
408
|
+
|
|
409
|
+
**Example.**
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
await client.buildAndDeploy("apps/flaky");
|
|
413
|
+
// ... do some user interactions ...
|
|
414
|
+
await client.assertAppAlive(); // fail fast if the app crashed
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### `detectSystemDialog`
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
static detectSystemDialog(tree: AppUiNode): SystemDialogNode | null;
|
|
421
|
+
detectSystemDialog(tree: AppUiNode): SystemDialogNode | null;
|
|
422
|
+
|
|
423
|
+
interface SystemDialogNode {
|
|
424
|
+
tag: string; // e.g. "StandardMessageDialog"
|
|
425
|
+
title: string; // attrs.title if present
|
|
426
|
+
node: AppUiNode;
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Static and instance form of the same predicate — scans root-level
|
|
431
|
+
children for one of the known obstructing dialog tags (`Dialog`,
|
|
432
|
+
`StandardMessageDialog`, `StandardDialog`, `ProblemDialog`,
|
|
433
|
+
`KeyboardDialog`, `PinEntryDialog`). Returns the first match or `null`.
|
|
434
|
+
|
|
435
|
+
`buildAndDeploy` and `launch` call this automatically after readiness
|
|
436
|
+
and fail the deploy with `DeployFailedError` when a dialog is present.
|
|
437
|
+
Direct callers typically only need it for bespoke recovery flows.
|
|
438
|
+
|
|
439
|
+
**Example.**
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const tree = await client.queryAppUi();
|
|
443
|
+
const dialog = RkClient.detectSystemDialog(tree);
|
|
444
|
+
if (dialog) {
|
|
445
|
+
console.warn(`Obstructing dialog: ${dialog.tag} ("${dialog.title}")`);
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Polling helpers
|
|
452
|
+
|
|
453
|
+
### `waitForNode`
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
waitForNode(predicate: NodePredicate, opts: WaitOptions): Promise<AppUiNode>;
|
|
457
|
+
|
|
458
|
+
interface NodePredicate {
|
|
459
|
+
tag?: string;
|
|
460
|
+
attr?: Record<string, string>;
|
|
461
|
+
focused?: boolean;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
interface WaitOptions {
|
|
465
|
+
timeout: number; // ms, required
|
|
466
|
+
interval?: number; // ms between iteration starts, default 200
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Polls `queryAppUi()` until `predicate` matches. Each iteration calls
|
|
471
|
+
`assertAppAlive` first, then fetches a fresh tree, then attempts the
|
|
472
|
+
match. `interval` is a **minimum** gap between iteration starts —
|
|
473
|
+
iterations that already took ≥ `interval` loop without extra sleep.
|
|
474
|
+
|
|
475
|
+
Throws `WaitForNodeTimeoutError` (with `context.lastTree` for
|
|
476
|
+
diagnostics) on timeout, or `AppCrashedError` if the app crashed
|
|
477
|
+
mid-wait.
|
|
478
|
+
|
|
479
|
+
**Example — first-render wait.**
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
await client.waitForNode(
|
|
483
|
+
{ attr: { id: "grid-item", text: "The Office" } },
|
|
484
|
+
{ timeout: 10_000 }
|
|
485
|
+
);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Example — focus wait with custom interval.**
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
await client.waitForNode(
|
|
492
|
+
{ focused: true, attr: { id: "play-button" } },
|
|
493
|
+
{ timeout: 3_000, interval: 100 }
|
|
494
|
+
);
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### `captureStableScreenshot`
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
captureStableScreenshot(opts: CaptureStableOptions): Promise<Buffer>;
|
|
501
|
+
|
|
502
|
+
interface CaptureStableOptions {
|
|
503
|
+
timeout: number;
|
|
504
|
+
interval?: number; // default 500 (matches the ECP queue gap)
|
|
505
|
+
mask?: MaskRegion[];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
interface MaskRegion {
|
|
509
|
+
x: number;
|
|
510
|
+
y: number;
|
|
511
|
+
width: number;
|
|
512
|
+
height: number;
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Polls until two successive captures are pixel-identical outside any
|
|
517
|
+
`mask` regions, then returns the **newer** PNG. Calls `assertAppAlive`
|
|
518
|
+
before each capture. On timeout throws `ScreenshotNotStableError` with
|
|
519
|
+
`iterations`, `lastDiffCount`, `firstDiffPoint`, `maskedRegions`, and
|
|
520
|
+
`lastPng`.
|
|
521
|
+
|
|
522
|
+
**Example.**
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
const png = await client.captureStableScreenshot({
|
|
526
|
+
timeout: 5_000,
|
|
527
|
+
mask: [{ x: 0, y: 0, width: 1920, height: 80 }] // blank the top bar
|
|
528
|
+
});
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
See [Screenshot assertions](screenshot-assertions.md) for the
|
|
532
|
+
`expectScreenshot` counterpart.
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Log access
|
|
537
|
+
|
|
538
|
+
The log-assertion API is fully documented in
|
|
539
|
+
[log-assertions.md](log-assertions.md). The method set exposed
|
|
540
|
+
directly on `RkClient`:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
ensureLogSession(): Promise<void>;
|
|
544
|
+
disposeLogSession(): void;
|
|
545
|
+
logBuffer(): LogBufferLike;
|
|
546
|
+
recentLogLines(n?: number): Array<{ seq: number; line: LogLine }>;
|
|
547
|
+
|
|
548
|
+
logPosition(): LogPosition;
|
|
549
|
+
logs(opts: { since: LogPosition }): Promise<LogLine[]>;
|
|
550
|
+
waitForLog(pattern: RegExp, opts: LogQueryOptions): Promise<LogLine>;
|
|
551
|
+
expectNoLog(pattern: RegExp, opts: LogQueryOptions): Promise<void>;
|
|
552
|
+
|
|
553
|
+
interface LogQueryOptions {
|
|
554
|
+
since: LogPosition;
|
|
555
|
+
timeout?: number; // for waitForLog
|
|
556
|
+
window?: number; // for expectNoLog (alias: timeout)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
type LogPosition = string; // opaque decimal seq
|
|
560
|
+
type LogLine = string;
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Causality example** — the canonical pattern for proving a keypress
|
|
564
|
+
caused a log line to appear:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
const pos = client.logPosition(); // SYNC — take baseline
|
|
568
|
+
await client.keypress("Select");
|
|
569
|
+
const line = await client.waitForLog(/select-pressed/, {
|
|
570
|
+
since: pos,
|
|
571
|
+
timeout: 2_000
|
|
572
|
+
});
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Common pitfall — `logPosition` must come before the causing action.**
|
|
576
|
+
Grabbing it after `keypress` means a fast-emitting log line can land
|
|
577
|
+
while `await keypress` is still in flight, making the `since` baseline
|
|
578
|
+
skip past it. The method is synchronous precisely so you can capture
|
|
579
|
+
it without a microtask boundary.
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## Parsers (internal / deep-import only)
|
|
584
|
+
|
|
585
|
+
`parseBounds(raw)` and `parseDeviceInfo(raw)` are used internally by
|
|
586
|
+
`RkClient.queryAppUi` and `RkClient.queryDeviceInfo` to turn Roku's
|
|
587
|
+
wire format into the typed shapes this page documents. They were
|
|
588
|
+
exported from the package root in v1.x but were **removed from the
|
|
589
|
+
barrel** in v2.0.0 — normal consumers get the parsed values directly
|
|
590
|
+
from the client methods (`(await client.queryAppUi()).bounds`,
|
|
591
|
+
`await client.queryDeviceInfo()`), which is what the tests in this
|
|
592
|
+
repository already do.
|
|
593
|
+
|
|
594
|
+
Reporters that post-process raw `/query/device-info` XML without
|
|
595
|
+
going through an `RkClient` instance can still reach them as
|
|
596
|
+
deep imports:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
// DEEP IMPORT — not covered by SemVer.
|
|
600
|
+
import { parseBounds, parseDeviceInfo } from "@roku-sdk/device-test-client/src/client/parsers";
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
If your consumer needs either on a stable public path, open an issue
|
|
604
|
+
so we can promote a dedicated export.
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Rate limits & retries
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
| Category | Interval (ms) | Notes |
|
|
612
|
+
| ------------------- | ------------- | -------------------------------------------------------- |
|
|
613
|
+
| `query-appui` | 200 | Matches the `waitForNode` polling cadence. |
|
|
614
|
+
| `query-device-info` | 0 | Cached after first fetch. |
|
|
615
|
+
| `query-active-app` | 0 | Crash probes fire on every polling tick. |
|
|
616
|
+
| `keypress` | 0 | Serialised via the queue; `keysequence` adds `keyDelay`. |
|
|
617
|
+
| `launch` | 0 | One-shot per test; pacing irrelevant. |
|
|
618
|
+
| `capture` | 500 | Expensive to render + encode server-side. |
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
All intervals are **gap-based** — measured between the *end* of one
|
|
622
|
+
call and the *start* of the next. Overridable for tests via
|
|
623
|
+
`RkClientOptions.intervals`.
|
|
624
|
+
|
|
625
|
+
**Retries.** Transient ECP failures (HTTP 5xx, network timeout, generic
|
|
626
|
+
`fetch failed`) are retried exactly once after a 500 ms backoff inside
|
|
627
|
+
the same queue slot. The second failure propagates verbatim as a
|
|
628
|
+
`ToolingError` (from `@roku-sdk/tooling`) so its `code` field stays
|
|
629
|
+
queryable — the SDK does not silently wrap it into an `RkClientError`.
|
|
630
|
+
|
|
631
|
+
---
|
|
632
|
+
|
|
633
|
+
## Errors thrown
|
|
634
|
+
|
|
635
|
+
Symbols below are the **class names**; full definitions and recovery
|
|
636
|
+
advice live in `[errors.md](errors.md)`.
|
|
637
|
+
|
|
638
|
+
- Construction — `RkClientError(RK_DEVICE_IP_UNRESOLVED)`
|
|
639
|
+
- Query parsing — `RkClientError(ECP_APPUI_PARSE_FAILED)`,
|
|
640
|
+
`RkClientError(ECP_ACTIVEAPP_PARSE_FAILED)`
|
|
641
|
+
- Invalid key — `RkClientError(ECP_INVALID_KEY)`
|
|
642
|
+
- Build / deploy / launch — `BuildFailedError`, `DeployFailedError`,
|
|
643
|
+
`DeviceUnhealthyError` (cascade guard)
|
|
644
|
+
- Readiness / health — `AppCrashedError`,
|
|
645
|
+
`DeployFailedError({ reason: "readiness-timeout" | "never-left-home" | "crashed-after-ready" | "system-dialog" })`.
|
|
646
|
+
The `never-left-home` reason latches the device **fatal**
|
|
647
|
+
(`markDeviceFatal` / `isDeviceFatal` / `getFatalReason`) — an
|
|
648
|
+
unrecoverable-on-this-VM wedge that disables recovery and fast-skips
|
|
649
|
+
downstream deploys — but only while no deploy has put a channel on
|
|
650
|
+
screen; after that it stays soft and retryable. See
|
|
651
|
+
[errors.md](errors.md#fatal-device-wedge)
|
|
652
|
+
- Polling — `WaitForNodeTimeoutError`, `AppCrashedError`,
|
|
653
|
+
`ScreenshotNotStableError`
|
|
654
|
+
- Logs (see [log-assertions.md](log-assertions.md)) —
|
|
655
|
+
`WaitForLogTimeoutError`, `UnexpectedLogError`, `LogStreamGapError`,
|
|
656
|
+
`LogBufferOverflowError`, `LogSessionStartError`
|
|
657
|
+
|