@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
package/docs/errors.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# Error reference
|
|
2
|
+
|
|
3
|
+
Every error class exported from `@roku-sdk/device-test-client` is
|
|
4
|
+
listed below with its declaration, context fields, typical causes,
|
|
5
|
+
and an `instanceof`-based narrowing example.
|
|
6
|
+
|
|
7
|
+
All errors extend `Error`, set a unique `.name` literal, and attach
|
|
8
|
+
structured context fields so CI reporters can render "id / title /
|
|
9
|
+
reason" columns without parsing the message string.
|
|
10
|
+
|
|
11
|
+
- [How to narrow errors](#how-to-narrow-errors)
|
|
12
|
+
- [RkClient / construction](#rkclient--construction)
|
|
13
|
+
- [Build / deploy](#build--deploy)
|
|
14
|
+
- [Lifecycle health](#lifecycle-health)
|
|
15
|
+
- [DOM assertions](#dom-assertions)
|
|
16
|
+
- [Screenshot assertions](#screenshot-assertions)
|
|
17
|
+
- [Log assertions](#log-assertions)
|
|
18
|
+
- [Preconditions](#preconditions)
|
|
19
|
+
|
|
20
|
+
## How to narrow errors
|
|
21
|
+
|
|
22
|
+
Use `instanceof` — every error class is exported from the package
|
|
23
|
+
root:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import {
|
|
27
|
+
WaitForNodeTimeoutError,
|
|
28
|
+
AppCrashedError
|
|
29
|
+
} from "@roku-sdk/device-test-client";
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await client.waitForNode({ attr: { id: "tile" } }, { timeout: 3_000 });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err instanceof AppCrashedError) {
|
|
35
|
+
console.log(`App crashed; active app: ${err.activeApp.title}`);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
if (err instanceof WaitForNodeTimeoutError) {
|
|
39
|
+
console.log(`Last tree had ${err.context.lastTree?.children.length} children`);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## RkClient / construction
|
|
49
|
+
|
|
50
|
+
### `RkClientError`
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
class RkClientError extends Error {
|
|
54
|
+
readonly name: "RkClientError";
|
|
55
|
+
readonly code?: string;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Thrown for framework-internal failures that are **not** wire /
|
|
60
|
+
transport errors. Known codes:
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
| code | Thrown by | Typical fix |
|
|
64
|
+
| ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
|
|
65
|
+
| `RK_DEVICE_IP_UNRESOLVED` | `new RkClient({})` with no IP; `resolveActiveDeviceIp` | Set `RK_DEVICE_IP` or `rk device add <ip>` |
|
|
66
|
+
| `ECP_INVALID_KEY` | `keypress`, `keysequence` | Use a valid Roku key name |
|
|
67
|
+
| `ECP_APPUI_PARSE_FAILED` | `queryAppUi` | Firmware bug / malformed XML; check `rk device ping` |
|
|
68
|
+
| `ECP_ACTIVEAPP_PARSE_FAILED` | `queryActiveApp` | Firmware bug / malformed XML |
|
|
69
|
+
| `PRECONDITION_NO_DEVICE_INFO` | `requirePrecondition` before cache populated | Call `installDeviceTestHooks` / `queryDeviceInfo` first |
|
|
70
|
+
| `PRECONDITION_INVALID_SEMVER_RANGE` | `requirePrecondition` / `itOnDevice` with bad range | Fix the semver range syntax |
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Wire errors propagate as `ToolingError` from `@roku-sdk/tooling`
|
|
74
|
+
without wrapping — their `code` field stays queryable downstream.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Build / deploy
|
|
79
|
+
|
|
80
|
+
### `BuildFailedError`
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
class BuildFailedError extends Error {
|
|
84
|
+
readonly name: "BuildFailedError";
|
|
85
|
+
readonly appPath: string;
|
|
86
|
+
readonly cause?: unknown;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Thrown by `buildAndDeploy` when the host-side build (TypeScript
|
|
91
|
+
compile, manifest parse, bundler) fails. **Does NOT mark the device
|
|
92
|
+
unhealthy** — the next test can succeed after a code fix without
|
|
93
|
+
device intervention.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
try {
|
|
97
|
+
await client.buildAndDeploy("apps/broken");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof BuildFailedError) {
|
|
100
|
+
console.error(`Build failed for ${err.appPath}:`, err.cause);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `DeployFailedError`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
class DeployFailedError extends Error {
|
|
109
|
+
readonly name: "DeployFailedError";
|
|
110
|
+
readonly context: {
|
|
111
|
+
appPath?: string;
|
|
112
|
+
deviceIp?: string;
|
|
113
|
+
diagnostics?: string;
|
|
114
|
+
lastTree?: AppUiNode;
|
|
115
|
+
lastActiveApp?: ActiveAppInfo;
|
|
116
|
+
dialog?: SystemDialogNode;
|
|
117
|
+
phase?: "launch" | "readiness" | "sideload" | "config";
|
|
118
|
+
expectedChannelId?: string;
|
|
119
|
+
reason?: string;
|
|
120
|
+
cause?: unknown;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Thrown by `buildAndDeploy` / `launch` / `buildAndLaunchWithParams`
|
|
126
|
+
when the device-side step fails. Every instance is paired with
|
|
127
|
+
`markDeviceUnhealthy` so the cascade guard aborts subsequent tests.
|
|
128
|
+
|
|
129
|
+
Known `context.reason` values: `"readiness-timeout"`,
|
|
130
|
+
`"never-left-home"`, `"crashed-after-ready"`, `"system-dialog"`,
|
|
131
|
+
`"sideload-rejected"`.
|
|
132
|
+
|
|
133
|
+
`"never-left-home"` is special. It means the device answered ECP for
|
|
134
|
+
the entire readiness budget but never foregrounded the sideloaded
|
|
135
|
+
channel — an **unrecoverable-on-this-VM** condition (a wedged
|
|
136
|
+
device host). It latches the device **fatal**
|
|
137
|
+
(`markDeviceFatal`), not merely unhealthy, so downstream deploys
|
|
138
|
+
fast-skip and the recovery `beforeEach` refuses to revive it. See
|
|
139
|
+
[Fatal device wedge](#fatal-device-wedge) below.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Lifecycle health
|
|
144
|
+
|
|
145
|
+
### `AppCrashedError`
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
class AppCrashedError extends Error {
|
|
149
|
+
readonly name: "AppCrashedError";
|
|
150
|
+
readonly activeApp: ActiveAppInfo;
|
|
151
|
+
readonly context?: unknown;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Thrown by `assertAppAlive` (and every polling helper that calls it)
|
|
156
|
+
when `/query/active-app` reports the home screen. `activeApp.title`
|
|
157
|
+
is `"Roku"` on the home screen.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
try {
|
|
161
|
+
await client.waitForNode(…);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof AppCrashedError) {
|
|
164
|
+
console.error("app crashed:", err.activeApp);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `DeviceUnhealthyError`
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
class DeviceUnhealthyError extends Error {
|
|
173
|
+
readonly name: "DeviceUnhealthyError";
|
|
174
|
+
readonly reason: string;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Thrown by `installDeviceTestHooks`'s `beforeEach` when a previous
|
|
179
|
+
test flipped the health flag. Aborts the remaining tests in the
|
|
180
|
+
worker fast so reviewers aren't wading through 50 cascade failures.
|
|
181
|
+
|
|
182
|
+
### Fatal device wedge
|
|
183
|
+
|
|
184
|
+
There are two device-health states, exposed as separate helper sets:
|
|
185
|
+
|
|
186
|
+
| Concept | Set | Reset by | Models |
|
|
187
|
+
|---------|-----|----------|--------|
|
|
188
|
+
| Soft health | `markDeviceUnhealthy` / `isDeviceHealthy` / `getUnhealthyReason` | `resetDeviceHealth()` | **Recoverable** fault (crash/reboot the device returns from). |
|
|
189
|
+
| Fatal latch | `markDeviceFatal` / `isDeviceFatal` / `getFatalReason` | *(process-lifetime; not cleared by `resetDeviceHealth`)* | **Unrecoverable-on-this-VM** fault — device answers ECP/installer but won't foreground a sideloaded channel. |
|
|
190
|
+
|
|
191
|
+
`markDeviceFatal` also flips the soft flag, so the existing cascade
|
|
192
|
+
keeps firing and you never need to call both. Currently only
|
|
193
|
+
`DeployFailedError({ reason: "never-left-home" })` latches fatal.
|
|
194
|
+
|
|
195
|
+
Consequences once fatal is latched (single `--runInBand` process):
|
|
196
|
+
|
|
197
|
+
- The recovery `beforeEach` in `installDeviceTestHooks` does **not**
|
|
198
|
+
attempt `waitForDeviceOnline` — a wedged VM still answers it, which
|
|
199
|
+
would falsely "recover" and re-run the test. It throws
|
|
200
|
+
`DeviceUnhealthyError` immediately with a "recycle the instance"
|
|
201
|
+
message.
|
|
202
|
+
- `installDeployGuard` (in `device-tests/`) short-circuits: remaining
|
|
203
|
+
suites skip their deploy entirely instead of burning the full
|
|
204
|
+
retry × backoff budget on a dead instance. The device host
|
|
205
|
+
must be recycled at the lease/runner layer.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## DOM assertions
|
|
210
|
+
|
|
211
|
+
### `NodeNotFoundError`
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
class NodeNotFoundError extends Error {
|
|
215
|
+
readonly name: "NodeNotFoundError";
|
|
216
|
+
readonly predicate: NodePredicate;
|
|
217
|
+
readonly treeSummary: string; // truncated pseudo-XML listing
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Thrown by `findNode` / `findFocusedNode` / `expectFocused`. The tree
|
|
222
|
+
summary is truncated to the first 3 levels / 2 000 chars so the
|
|
223
|
+
reporter stays scannable.
|
|
224
|
+
|
|
225
|
+
### `FocusedNodeMismatchError`
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
class FocusedNodeMismatchError extends Error {
|
|
229
|
+
readonly name: "FocusedNodeMismatchError";
|
|
230
|
+
readonly expectedPredicate: NodePredicate;
|
|
231
|
+
readonly actualFocusedNode: string; // one-line summary of the focused node
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Thrown by `expectFocused` when a node IS focused but doesn't match
|
|
236
|
+
the predicate.
|
|
237
|
+
|
|
238
|
+
### `WaitForNodeTimeoutError`
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
class WaitForNodeTimeoutError extends Error {
|
|
242
|
+
readonly name: "WaitForNodeTimeoutError";
|
|
243
|
+
readonly context: {
|
|
244
|
+
predicate: NodePredicate;
|
|
245
|
+
timeoutMs: number;
|
|
246
|
+
lastTree: AppUiNode | null;
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Thrown by `client.waitForNode` on timeout. `context.lastTree` is the
|
|
252
|
+
final snapshot the polling loop observed.
|
|
253
|
+
|
|
254
|
+
### `BaselineMissingError`
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
class BaselineMissingError extends Error {
|
|
258
|
+
readonly name: "BaselineMissingError";
|
|
259
|
+
readonly path: string; // absolute baseline path
|
|
260
|
+
readonly resolution: string; // "hd" | "fhd"
|
|
261
|
+
readonly videoMode: string | undefined;
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Thrown by `expectTree(…).toMatchBaseline(path)` when the file does
|
|
266
|
+
not exist and `UPDATE_BASELINES=1` was not set.
|
|
267
|
+
|
|
268
|
+
### `BaselineResolutionError`
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
class BaselineResolutionError extends Error {
|
|
272
|
+
readonly name: "BaselineResolutionError";
|
|
273
|
+
readonly context: {
|
|
274
|
+
videoMode?: string;
|
|
275
|
+
reason: "unsupported-videoMode" | "no-active-client-and-no-env" | "invalid-env-value";
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Thrown when `hd` / `fhd` subdirectory cannot be determined. See
|
|
281
|
+
[baselines.md](baselines.md#resolution-routing) for the precedence
|
|
282
|
+
table.
|
|
283
|
+
|
|
284
|
+
### `TreeMismatchError`
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
class TreeMismatchError extends Error {
|
|
288
|
+
readonly name: "TreeMismatchError";
|
|
289
|
+
readonly context: {
|
|
290
|
+
baselinePath: string;
|
|
291
|
+
firstDiff: string; // JSONPath (e.g. "$.children[0].attrs.opacity")
|
|
292
|
+
expectedSnippet: string; // truncated baseline snippet
|
|
293
|
+
actualSnippet: string; // truncated actual snippet
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Thrown by `expectTree(…).toMatchBaseline(…)` when the tree diverges
|
|
299
|
+
from the baseline.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Screenshot assertions
|
|
304
|
+
|
|
305
|
+
### `ScreenshotNotStableError`
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
class ScreenshotNotStableError extends Error {
|
|
309
|
+
readonly name: "ScreenshotNotStableError";
|
|
310
|
+
readonly timeoutMs: number;
|
|
311
|
+
readonly iterations: number;
|
|
312
|
+
readonly lastDiffCount: number;
|
|
313
|
+
readonly maskedRegions: MaskRegion[];
|
|
314
|
+
readonly firstDiffPoint?: { x: number; y: number };
|
|
315
|
+
readonly lastPng?: Buffer;
|
|
316
|
+
|
|
317
|
+
toJSON(): Record<string, unknown>; // omits lastPng
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Thrown by `captureStableScreenshot` on timeout. `lastPng` is the most
|
|
322
|
+
recent capture; `toJSON` explicitly omits it so
|
|
323
|
+
`JSON.stringify(err)` doesn't balloon the CI log.
|
|
324
|
+
|
|
325
|
+
### `ScreenshotBaselineMissingError`
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
class ScreenshotBaselineMissingError extends Error {
|
|
329
|
+
readonly name: "ScreenshotBaselineMissingError";
|
|
330
|
+
readonly baselinePath: string;
|
|
331
|
+
readonly resolution: "hd" | "fhd";
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Thrown by `expectScreenshot(…).toMatchBaseline(…)` when the baseline
|
|
336
|
+
PNG does not exist and `UPDATE_BASELINES=1` was not set.
|
|
337
|
+
|
|
338
|
+
### `ScreenshotDimensionsMismatchError`
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
class ScreenshotDimensionsMismatchError extends Error {
|
|
342
|
+
readonly name: "ScreenshotDimensionsMismatchError";
|
|
343
|
+
readonly context: {
|
|
344
|
+
baselinePath: string;
|
|
345
|
+
resolution: "hd" | "fhd";
|
|
346
|
+
baseline: { w: number; h: number };
|
|
347
|
+
actual: { w: number; h: number };
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Thrown when baseline and actual PNGs have different sizes. No diff
|
|
353
|
+
PNG is written in this path.
|
|
354
|
+
|
|
355
|
+
### `ScreenshotMismatchError`
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
class ScreenshotMismatchError extends Error {
|
|
359
|
+
readonly name: "ScreenshotMismatchError";
|
|
360
|
+
readonly context: {
|
|
361
|
+
baselinePath: string;
|
|
362
|
+
resolution: "hd" | "fhd";
|
|
363
|
+
actual: { w: number; h: number };
|
|
364
|
+
mismatchedPixels: number;
|
|
365
|
+
totalPixels: number;
|
|
366
|
+
ratio: number;
|
|
367
|
+
threshold: number;
|
|
368
|
+
diffPath: string;
|
|
369
|
+
alignment?: { dx: number; dy: number };
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Thrown when the pixel-mismatch ratio exceeds the threshold. The diff
|
|
375
|
+
PNG is **always** written to `diffPath` before the error is thrown
|
|
376
|
+
— reporters that swallow the error can still attach it.
|
|
377
|
+
|
|
378
|
+
`alignment` appears only when an `alignTolerance` search found a
|
|
379
|
+
translation that scored better than the untranslated comparison, and
|
|
380
|
+
names the offset the reported `ratio` was measured at. A non-zero
|
|
381
|
+
`alignment` on a failure means the surface both moved and changed.
|
|
382
|
+
|
|
383
|
+
### `ScreenshotMaskError`
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
class ScreenshotMaskError extends Error {
|
|
387
|
+
readonly name: "ScreenshotMaskError";
|
|
388
|
+
readonly context: {
|
|
389
|
+
region: MaskRegion;
|
|
390
|
+
canvas: { w: number; h: number };
|
|
391
|
+
reason: "out-of-bounds" | "zero-area";
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Thrown for malformed `MaskRegion` values. Partial overlap is NOT an
|
|
397
|
+
error — those regions are clamped silently.
|
|
398
|
+
|
|
399
|
+
### `ScreenshotAlignToleranceError`
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
class ScreenshotAlignToleranceError extends Error {
|
|
403
|
+
readonly name: "ScreenshotAlignToleranceError";
|
|
404
|
+
readonly context: {
|
|
405
|
+
axis: "x" | "y";
|
|
406
|
+
value: number;
|
|
407
|
+
max: number;
|
|
408
|
+
reason: "not-an-integer" | "negative" | "over-cap";
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Thrown for a malformed `alignTolerance` axis. Unlike masks, nothing is
|
|
414
|
+
clamped — a bad value is a caller bug, and it is validated before the
|
|
415
|
+
comparison so it surfaces on passing runs too.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Log assertions
|
|
420
|
+
|
|
421
|
+
### `WaitForLogTimeoutError`
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
class WaitForLogTimeoutError extends Error {
|
|
425
|
+
readonly name: "WaitForLogTimeoutError";
|
|
426
|
+
readonly pattern: string;
|
|
427
|
+
readonly since: LogPosition;
|
|
428
|
+
readonly timeoutMs: number;
|
|
429
|
+
readonly linesChecked: number;
|
|
430
|
+
readonly recentLines: LogLine[]; // last 20 entries, capped
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Thrown by `waitForLog` on timeout.
|
|
435
|
+
|
|
436
|
+
### `UnexpectedLogError`
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
class UnexpectedLogError extends Error {
|
|
440
|
+
readonly name: "UnexpectedLogError";
|
|
441
|
+
readonly pattern: string;
|
|
442
|
+
readonly matchedLine: LogLine;
|
|
443
|
+
readonly since: LogPosition;
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Thrown by `expectNoLog` when the forbidden pattern matched a line in
|
|
448
|
+
the window.
|
|
449
|
+
|
|
450
|
+
### `LogStreamGapError`
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
class LogStreamGapError extends Error {
|
|
454
|
+
readonly name: "LogStreamGapError";
|
|
455
|
+
readonly gapStart: LogPosition;
|
|
456
|
+
readonly gapEnd: LogPosition;
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Thrown by `waitForLog` / `expectNoLog` / `logs` when an SSE
|
|
461
|
+
reconnect gap overlaps the query range — the missing lines *might*
|
|
462
|
+
have matched, so we fail rather than silently pass.
|
|
463
|
+
|
|
464
|
+
### `LogBufferOverflowError`
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
class LogBufferOverflowError extends Error {
|
|
468
|
+
readonly name: "LogBufferOverflowError";
|
|
469
|
+
readonly requested: LogPosition;
|
|
470
|
+
readonly minAvailable: LogPosition;
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Thrown when a `since` position references lines the buffer has
|
|
475
|
+
already evicted. Fix: raise `LogBuffer` cap, take fresh
|
|
476
|
+
`logPosition()` more often, or shorten assertion windows.
|
|
477
|
+
|
|
478
|
+
### `LogSessionStartError`
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
class LogSessionStartError extends Error {
|
|
482
|
+
readonly name: "LogSessionStartError";
|
|
483
|
+
readonly serverUrl: string;
|
|
484
|
+
readonly statusCode?: number;
|
|
485
|
+
readonly detail?: string;
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Thrown when `POST /logs/start` fails with an unrecoverable HTTP error
|
|
490
|
+
or SSE attach fails before ready. A 400 "already active" response is
|
|
491
|
+
NOT a failure — the session attaches to the existing server-side
|
|
492
|
+
session.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Preconditions
|
|
497
|
+
|
|
498
|
+
### `PreconditionNotMetError`
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
class PreconditionNotMetError extends Error {
|
|
502
|
+
readonly name: "PreconditionNotMetError";
|
|
503
|
+
readonly field: string;
|
|
504
|
+
readonly expected: string;
|
|
505
|
+
readonly actual: string;
|
|
506
|
+
readonly reason: "mismatch" | "missing-device-info" | "not-implemented";
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Thrown by `requirePrecondition`. Registration-time wrappers
|
|
511
|
+
(`itOnDevice` / `describeOnDevice`) catch this class and convert it
|
|
512
|
+
to `it.skip()` / `describe.skip()`.
|
|
513
|
+
|
|
514
|
+
### `PreconditionNotImplementedError`
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
class PreconditionNotImplementedError extends PreconditionNotMetError {
|
|
518
|
+
readonly name: "PreconditionNotImplementedError";
|
|
519
|
+
readonly feature: string; // e.g. "library-version"
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Extends `PreconditionNotMetError` so the skip-converter handles it
|
|
524
|
+
identically. Thrown when the caller asks for a field the SDK cannot
|
|
525
|
+
query (today: `library`).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Example: deep-link cold start
|
|
2
|
+
|
|
3
|
+
`buildAndLaunchWithParams` deploys an app and immediately launches it
|
|
4
|
+
with ECP `contentId` / `mediaType` query parameters. The footgun: on a
|
|
5
|
+
cold sideload the launch parameters race with the channel's own
|
|
6
|
+
cold-start code path, so this example gates readiness on both a DOM
|
|
7
|
+
node AND a log pattern.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- An app that logs a deterministic line once it has parsed the
|
|
12
|
+
launch params (e.g. `deep-link:parsed contentId=<id>`).
|
|
13
|
+
- A DOM node that appears only on the deep-linked screen
|
|
14
|
+
(e.g. `id="detail-screen"`).
|
|
15
|
+
|
|
16
|
+
## Test file
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// tests/deep-link.test.ts
|
|
20
|
+
import {
|
|
21
|
+
installDeviceTestHooks,
|
|
22
|
+
findNode
|
|
23
|
+
} from "@roku-sdk/device-test-client";
|
|
24
|
+
|
|
25
|
+
const ctx = installDeviceTestHooks();
|
|
26
|
+
|
|
27
|
+
describe("deep link cold start", () => {
|
|
28
|
+
test("opens detail screen for the deep-linked content id", async () => {
|
|
29
|
+
const client = ctx.getClient();
|
|
30
|
+
const contentId = "movie-42";
|
|
31
|
+
|
|
32
|
+
const pos = client.logPosition();
|
|
33
|
+
|
|
34
|
+
await client.buildAndLaunchWithParams("apps/video", {
|
|
35
|
+
contentId,
|
|
36
|
+
mediaType: "movie",
|
|
37
|
+
readyNode: { attr: { id: "detail-screen" } },
|
|
38
|
+
readinessTimeout: 15_000
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await client.waitForLog(
|
|
42
|
+
new RegExp(`deep-link:parsed contentId=${contentId}`),
|
|
43
|
+
{ since: pos, timeout: 10_000 }
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const tree = await client.queryAppUi();
|
|
47
|
+
const title = findNode(tree, {
|
|
48
|
+
tag: "Label",
|
|
49
|
+
attr: { id: "content-title" }
|
|
50
|
+
});
|
|
51
|
+
expect(title.attrs.text).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Walkthrough
|
|
57
|
+
|
|
58
|
+
1. **Snapshot the log position first.** `logPosition()` is O(1) and
|
|
59
|
+
cheap. We capture it *before* starting the deploy so the log
|
|
60
|
+
window covers the entire launch sequence. If we snapshotted after
|
|
61
|
+
`buildAndLaunchWithParams`, the parse line would likely be older
|
|
62
|
+
than `since`, and `waitForLog` would timeout even though the app
|
|
63
|
+
did the right thing.
|
|
64
|
+
2. **Deploy + launch with readiness.** `readyNode` polls
|
|
65
|
+
`queryAppUi` until the detail screen renders. `readinessTimeout`
|
|
66
|
+
is bumped to 15 s for cold-start scenarios where bundle hydration
|
|
67
|
+
dominates.
|
|
68
|
+
3. **Await the app-level ack.** Readiness says the node appeared;
|
|
69
|
+
the log gate says the app actually parsed and respected the
|
|
70
|
+
contentId. Both together rule out the "generic home screen with a
|
|
71
|
+
label that happens to match" failure mode.
|
|
72
|
+
4. **Verify content.** Trivial DOM assertion once the gates pass.
|
|
73
|
+
|
|
74
|
+
## Common pitfalls
|
|
75
|
+
|
|
76
|
+
- **Don't skip the `logPosition()` snapshot.** Every `waitForLog`
|
|
77
|
+
without `since` scans from the buffer's minimum retained line,
|
|
78
|
+
which on long-lived workers can be minutes old.
|
|
79
|
+
- **Use readiness that is exclusive to the deep link.** A generic
|
|
80
|
+
`id="main"` node is often present on the home screen too — the
|
|
81
|
+
test will go green before the app actually navigated.
|
|
82
|
+
- **If the app's log line is non-deterministic,** gate on DOM only
|
|
83
|
+
and add a second `waitForNode` for a deep-link-only sub-element
|
|
84
|
+
(e.g. a back button that only exists on detail screens).
|
|
85
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Example: focus navigation
|
|
2
|
+
|
|
3
|
+
End-to-end test that drives a grid with directional keys and asserts
|
|
4
|
+
the focused node after each step. Demonstrates the
|
|
5
|
+
`keysequence` + `waitForNode({ focused: true })` idiom.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- An app with a navigable tile grid where each tile has a stable
|
|
10
|
+
`id` attribute (e.g. `tile-0`, `tile-1`, `tile-2`).
|
|
11
|
+
|
|
12
|
+
## Test file
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// tests/focus-nav.test.ts
|
|
16
|
+
import {
|
|
17
|
+
installDeviceTestHooks,
|
|
18
|
+
expectFocused,
|
|
19
|
+
findFocusedNode
|
|
20
|
+
} from "@roku-sdk/device-test-client";
|
|
21
|
+
|
|
22
|
+
const ctx = installDeviceTestHooks();
|
|
23
|
+
|
|
24
|
+
describe("grid focus navigation", () => {
|
|
25
|
+
test("Right, Right, Down lands on tile-5", async () => {
|
|
26
|
+
const client = ctx.getClient();
|
|
27
|
+
|
|
28
|
+
await client.buildAndDeploy("apps/grid", {
|
|
29
|
+
readyNode: { attr: { id: "grid" } }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const initial = await client.queryAppUi();
|
|
33
|
+
expectFocused(initial, { attr: { id: "tile-0" } });
|
|
34
|
+
|
|
35
|
+
await client.keysequence(["Right", "Right"], { keyDelay: 200 });
|
|
36
|
+
const row1 = await client.waitForNode(
|
|
37
|
+
{ focused: true, attr: { id: "tile-2" } },
|
|
38
|
+
{ timeout: 2_000 }
|
|
39
|
+
);
|
|
40
|
+
expect(row1.attrs.id).toBe("tile-2");
|
|
41
|
+
|
|
42
|
+
await client.keypress("Down");
|
|
43
|
+
const focused = findFocusedNode(
|
|
44
|
+
await client.queryAppUi()
|
|
45
|
+
);
|
|
46
|
+
expect(focused?.attrs.id).toBe("tile-5");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Walkthrough
|
|
52
|
+
|
|
53
|
+
1. `expectFocused(tree, predicate)` is synchronous — it asserts on
|
|
54
|
+
an already-captured tree. Use it when the previous action
|
|
55
|
+
guaranteed the device is steady (e.g. just after
|
|
56
|
+
`buildAndDeploy` readiness).
|
|
57
|
+
2. `keysequence(["Right", "Right"], { keyDelay: 200 })` sends two
|
|
58
|
+
keys with a 200 ms pause between them. Override the default
|
|
59
|
+
100 ms (`DEFAULT_KEY_DELAY`) when your app's focus-change
|
|
60
|
+
animations are slow.
|
|
61
|
+
3. `waitForNode({ focused: true, attr: { id: "tile-2" } })` polls
|
|
62
|
+
`queryAppUi` until BOTH the `focused: true` bit and the id
|
|
63
|
+
predicate match on the same node. This is more robust than
|
|
64
|
+
asserting each dimension separately because it tolerates the
|
|
65
|
+
frame where focus has moved off tile-0 but not yet landed on
|
|
66
|
+
tile-2.
|
|
67
|
+
4. `findFocusedNode` returns `null` if nothing is focused — prefer
|
|
68
|
+
`expectFocused` when you want a thrown
|
|
69
|
+
`NodeNotFoundError` / `FocusedNodeMismatchError` instead.
|
|
70
|
+
|
|
71
|
+
## Common pitfalls
|
|
72
|
+
|
|
73
|
+
- **Don't sleep between keypresses.** Use `keyDelay` or
|
|
74
|
+
`waitForNode({ focused: true, … })` rather than arbitrary
|
|
75
|
+
`setTimeout` — it's slower on fast devices and flaky on slow ones.
|
|
76
|
+
- **Assert node identity, not just "focused: true".** A test that
|
|
77
|
+
only checks `{ focused: true }` passes as long as *some* node has
|
|
78
|
+
focus, even if it's the wrong one.
|
|
79
|
+
- **Beware RTL apps.** `Right` / `Left` key semantics flip; prefer
|
|
80
|
+
`FocusChange` log lines when targeting locales with RTL layout.
|
|
81
|
+
|