@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Example: log-driven assertion
|
|
2
|
+
|
|
3
|
+
End-to-end causality pattern: snapshot a log position, trigger a
|
|
4
|
+
device action, then `waitForLog` for the app-level ack. This is the
|
|
5
|
+
canonical way to observe behaviour that *isn't* reflected in the DOM
|
|
6
|
+
(analytics, network fires, side effects).
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
- App emits a deterministic log line when the observed action
|
|
11
|
+
completes (e.g.
|
|
12
|
+
`analytics:track event=tile_click id=tile-7`).
|
|
13
|
+
- Log streaming has been installed — `installDeviceTestHooks` wires
|
|
14
|
+
`ensureLogSession` automatically in `beforeEach`.
|
|
15
|
+
|
|
16
|
+
## Test file
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// tests/analytics.test.ts
|
|
20
|
+
import {
|
|
21
|
+
installDeviceTestHooks
|
|
22
|
+
} from "@roku-sdk/device-test-client";
|
|
23
|
+
|
|
24
|
+
const ctx = installDeviceTestHooks();
|
|
25
|
+
|
|
26
|
+
describe("tile click analytics", () => {
|
|
27
|
+
test("fires track event with correct id", async () => {
|
|
28
|
+
const client = ctx.getClient();
|
|
29
|
+
|
|
30
|
+
await client.buildAndDeploy("apps/grid", {
|
|
31
|
+
readyNode: { attr: { id: "grid" } }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ─── Causality: snapshot → action → wait ───────────────────
|
|
35
|
+
const pos = client.logPosition();
|
|
36
|
+
await client.keypress("Select");
|
|
37
|
+
|
|
38
|
+
const line = await client.waitForLog(
|
|
39
|
+
/analytics:track event=(\w+) id=(\S+)/,
|
|
40
|
+
{ since: pos, timeout: 3_000 }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const [, event, id] = line.message.match(
|
|
44
|
+
/analytics:track event=(\w+) id=(\S+)/
|
|
45
|
+
)!;
|
|
46
|
+
expect(event).toBe("tile_click");
|
|
47
|
+
expect(id).toMatch(/^tile-\d+$/);
|
|
48
|
+
|
|
49
|
+
// ─── Negative assertion: no double-fire within 500 ms ──────
|
|
50
|
+
await client.expectNoLog(/analytics:track event=tile_click/, {
|
|
51
|
+
since: pos,
|
|
52
|
+
window: 500,
|
|
53
|
+
// `pos` is before the keypress, so this tests "no OTHER
|
|
54
|
+
// tile_click event landed beyond the one we just matched".
|
|
55
|
+
// For strict no-double-fire, snapshot `pos2 = logPosition()`
|
|
56
|
+
// AFTER the first match and use that instead.
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The causality pattern
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
T=0 client.logPosition() ← snapshot
|
|
66
|
+
T=0+ε client.keypress("Select") ← trigger
|
|
67
|
+
T=0+Δ device emits log line ← observation
|
|
68
|
+
T=Δ+n waitForLog matches ← assertion
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `since: pos` argument is what makes this reliable. Without it,
|
|
72
|
+
`waitForLog` scans the buffer's minimum retained seq — which on a
|
|
73
|
+
long-lived worker could match a line from a previous test.
|
|
74
|
+
|
|
75
|
+
### Negative assertions
|
|
76
|
+
|
|
77
|
+
`expectNoLog(pattern, { since, window })` runs the opposite: it
|
|
78
|
+
throws `UnexpectedLogError` if *any* line in the window matches.
|
|
79
|
+
Use it for "didn't accidentally navigate away", "didn't log an
|
|
80
|
+
error", "didn't double-fire".
|
|
81
|
+
|
|
82
|
+
`window` is optional — without it, `expectNoLog` evaluates
|
|
83
|
+
immediately against already-buffered lines and returns.
|
|
84
|
+
|
|
85
|
+
## Common pitfalls
|
|
86
|
+
|
|
87
|
+
- **Don't snapshot AFTER the action.** The observed log line may
|
|
88
|
+
arrive before the snapshot; subsequent `waitForLog` never
|
|
89
|
+
matches.
|
|
90
|
+
- **Use `{ since }`, not `{ timeout }` alone.** `timeout` is a
|
|
91
|
+
watchdog; `since` is what scopes the window. Both are required
|
|
92
|
+
for deterministic tests.
|
|
93
|
+
- **Handle `LogStreamGapError`.** If the SSE stream dropped and
|
|
94
|
+
reconnected during the window, the missing lines could have
|
|
95
|
+
matched. Decide per-test whether to retry or surface.
|
|
96
|
+
- **Don't leak buffer state between tests.** `installDeviceTestHooks`
|
|
97
|
+
calls `disposeLogSession` in `afterEach` so each test starts with
|
|
98
|
+
a fresh position. Custom setups must mirror that.
|
|
99
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Example: precondition skips
|
|
2
|
+
|
|
3
|
+
Mix `itOnDevice` and `describeOnDevice` to gate tests on firmware,
|
|
4
|
+
device class, locale, and video mode. Demonstrates the difference
|
|
5
|
+
between "skip with reason" (registration-time wrappers) and "fail
|
|
6
|
+
with reason" (runtime `requirePrecondition`).
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
- `createGlobalSetup` has run and written `test-results/device-env.json`
|
|
11
|
+
— preconditions read the cached `DeviceInfo` synchronously at
|
|
12
|
+
registration time.
|
|
13
|
+
|
|
14
|
+
## Test file
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// tests/precondition-matrix.test.ts
|
|
18
|
+
import {
|
|
19
|
+
describeOnDevice,
|
|
20
|
+
itOnDevice,
|
|
21
|
+
requirePrecondition,
|
|
22
|
+
installDeviceTestHooks
|
|
23
|
+
} from "@roku-sdk/device-test-client";
|
|
24
|
+
|
|
25
|
+
const ctx = installDeviceTestHooks();
|
|
26
|
+
|
|
27
|
+
// Whole suite gated on device class + video mode.
|
|
28
|
+
describeOnDevice(
|
|
29
|
+
"4K playback",
|
|
30
|
+
() => {
|
|
31
|
+
itOnDevice(
|
|
32
|
+
"plays HEVC content",
|
|
33
|
+
async () => {
|
|
34
|
+
const client = ctx.getClient();
|
|
35
|
+
await client.buildAndDeploy("apps/player");
|
|
36
|
+
// ...
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
itOnDevice(
|
|
41
|
+
"uses HDR API (firmware 14+)",
|
|
42
|
+
async () => {
|
|
43
|
+
const client = ctx.getClient();
|
|
44
|
+
// ...
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
preconditions: { firmwareVersion: ">=14.0" }
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
preconditions: {
|
|
53
|
+
deviceClass: "high-end",
|
|
54
|
+
videoMode: "2160p"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
describe("locale-specific UI", () => {
|
|
60
|
+
it("renders Spanish strings", async () => {
|
|
61
|
+
// Runtime form — fails the test if the device is not es-ES.
|
|
62
|
+
requirePrecondition({ locale: "es-ES" });
|
|
63
|
+
|
|
64
|
+
const client = ctx.getClient();
|
|
65
|
+
await client.buildAndDeploy("apps/i18n");
|
|
66
|
+
// ...
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Walkthrough
|
|
72
|
+
|
|
73
|
+
### `describeOnDevice` — whole-suite gate
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
describeOnDevice("suite name", body, { preconditions: {…} });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If the device doesn't satisfy every precondition, the suite
|
|
80
|
+
registers as `describe.skip(...)` with the reason interpolated into
|
|
81
|
+
the title. **All** `itOnDevice` and plain `it` inside the block
|
|
82
|
+
skip with it.
|
|
83
|
+
|
|
84
|
+
### `itOnDevice` — per-test gate
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
itOnDevice("test name", body, { preconditions: {…} });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Evaluates preconditions at registration time. If the device lacks
|
|
91
|
+
the required fields, the test registers as `it.skip(...)`. If the
|
|
92
|
+
device DOES satisfy them, the test runs and its callback executes
|
|
93
|
+
with the usual `installDeviceTestHooks` plumbing.
|
|
94
|
+
|
|
95
|
+
Inside a `describeOnDevice` block, `itOnDevice`'s preconditions
|
|
96
|
+
**merge with** the enclosing block's — both must pass for the
|
|
97
|
+
test to run.
|
|
98
|
+
|
|
99
|
+
### `requirePrecondition` — runtime gate
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
it("something", async () => {
|
|
103
|
+
requirePrecondition({ locale: "es-ES" });
|
|
104
|
+
// ...
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Throws `PreconditionNotMetError` inside the test body. Jest reports
|
|
109
|
+
the test as *failed*, not skipped. Use this only when the check
|
|
110
|
+
genuinely needs to be runtime (e.g. depends on another test's side
|
|
111
|
+
effect).
|
|
112
|
+
|
|
113
|
+
## Precondition matching rules
|
|
114
|
+
|
|
115
|
+
| Field | Matcher | Example |
|
|
116
|
+
|-------|---------|---------|
|
|
117
|
+
| `firmwareVersion` | semver range | `">=12.0"`, `"14.x"`, `"<15.0"` |
|
|
118
|
+
| `modelFamily` | exact string | `"Roku Ultra"` |
|
|
119
|
+
| `deviceClass` | derived tier | `"high-end"` \| `"mid-range"` \| `"low-end"` |
|
|
120
|
+
| `videoMode` | prefix | `"2160p"` matches `"2160p60"`, `"2160p"` |
|
|
121
|
+
| `locale` | exact string | `"en-US"` |
|
|
122
|
+
| `country` | exact string | `"US"` |
|
|
123
|
+
| `library` | **not implemented** — throws `PreconditionNotImplementedError` |
|
|
124
|
+
|
|
125
|
+
`deviceClass` is derived from `modelName` via
|
|
126
|
+
[`getDeviceClass`](../preconditions.md#devicecomputing-tiers).
|
|
127
|
+
See the full tier table there.
|
|
128
|
+
|
|
129
|
+
## Skip vs fail cheat sheet
|
|
130
|
+
|
|
131
|
+
| Wrapper | Missing device info | Field mismatch | Not-implemented field |
|
|
132
|
+
|---------|---------------------|----------------|-----------------------|
|
|
133
|
+
| `itOnDevice` | skip + "missing device info" reason | skip + reason | skip + "not implemented" reason |
|
|
134
|
+
| `describeOnDevice` | skip whole block | skip whole block | skip whole block |
|
|
135
|
+
| `requirePrecondition` | throws `PreconditionNotMetError({reason:"missing-device-info"})` | throws `PreconditionNotMetError({reason:"mismatch"})` | throws `PreconditionNotImplementedError` |
|
|
136
|
+
|
|
137
|
+
## Common pitfalls
|
|
138
|
+
|
|
139
|
+
- **`device-env.json` must exist at registration time.** If a worker
|
|
140
|
+
starts before `globalSetup` writes the file, preconditions
|
|
141
|
+
degrade gracefully to "skip" with the `missing-device-info`
|
|
142
|
+
reason — tests don't run but no failures either. Hooks-based
|
|
143
|
+
setups never hit this.
|
|
144
|
+
- **`library` is not yet implemented.** Any precondition
|
|
145
|
+
referencing it throws `PreconditionNotImplementedError`, which
|
|
146
|
+
the registration-time wrappers turn into `it.skip(...)` so the
|
|
147
|
+
test doesn't regress to red.
|
|
148
|
+
- **Prefer `itOnDevice` over `requirePrecondition`.** Skip is the
|
|
149
|
+
correct CI signal for "device can't run this test"; fail is
|
|
150
|
+
reserved for "device should have been able but wasn't".
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Example: screenshot regression
|
|
2
|
+
|
|
3
|
+
Stable-capture + baseline-diff flow with a mask for the live clock
|
|
4
|
+
widget. Demonstrates how to gate screenshot assertions on frame
|
|
5
|
+
stability and how to regenerate baselines without accidentally
|
|
6
|
+
green-lighting real regressions.
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
- Baseline PNG committed at
|
|
11
|
+
`baselines/hd/home/initial.png` (or `fhd/` if your device runs
|
|
12
|
+
1080p). See [baselines.md](../baselines.md) for the layout rules.
|
|
13
|
+
- `setBaselineRoot(...)` called during Jest's `setupFilesAfterEnv`.
|
|
14
|
+
|
|
15
|
+
## Test file
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// tests/screenshot-home.test.ts
|
|
19
|
+
import {
|
|
20
|
+
installDeviceTestHooks,
|
|
21
|
+
expectScreenshot,
|
|
22
|
+
type MaskRegion
|
|
23
|
+
} from "@roku-sdk/device-test-client";
|
|
24
|
+
|
|
25
|
+
const ctx = installDeviceTestHooks();
|
|
26
|
+
|
|
27
|
+
const CLOCK_MASK: MaskRegion = {
|
|
28
|
+
// top-right HH:MM, coords in "hd" space
|
|
29
|
+
x: 1120,
|
|
30
|
+
y: 20,
|
|
31
|
+
width: 140,
|
|
32
|
+
height: 40
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("home screen regression", () => {
|
|
36
|
+
test("matches baseline on boot", async () => {
|
|
37
|
+
const client = ctx.getClient();
|
|
38
|
+
|
|
39
|
+
await client.buildAndDeploy("apps/home", {
|
|
40
|
+
readyNode: { attr: { id: "home-screen" } }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const png = await client.captureStableScreenshot({
|
|
44
|
+
timeout: 8_000,
|
|
45
|
+
interval: 500,
|
|
46
|
+
mask: [CLOCK_MASK]
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await expectScreenshot(png).toMatchBaseline("home/initial.png", {
|
|
50
|
+
threshold: 0.005,
|
|
51
|
+
mask: [CLOCK_MASK]
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Walkthrough
|
|
58
|
+
|
|
59
|
+
1. `captureStableScreenshot` loops `captureScreenshot` until two
|
|
60
|
+
consecutive PNGs are pixel-identical outside the mask, or
|
|
61
|
+
`timeout` elapses (then throws `ScreenshotNotStableError`). The
|
|
62
|
+
mask is applied inside the stability check too — otherwise the
|
|
63
|
+
ticking clock would keep the loop running forever.
|
|
64
|
+
2. `expectScreenshot(png).toMatchBaseline(path, options)`:
|
|
65
|
+
- Resolves `path` against `baselineRoot` + the active client's
|
|
66
|
+
resolution subdir (`hd/` or `fhd/`).
|
|
67
|
+
- On missing baseline + `UPDATE_BASELINES=1`: writes the PNG
|
|
68
|
+
atomically and returns. Without the env var: throws
|
|
69
|
+
`ScreenshotBaselineMissingError`.
|
|
70
|
+
- On dimension mismatch: throws
|
|
71
|
+
`ScreenshotDimensionsMismatchError` (no diff written).
|
|
72
|
+
- On pixel mismatch over `threshold`: writes
|
|
73
|
+
`test-results/diffs/<flattened>.diff.png` and throws
|
|
74
|
+
`ScreenshotMismatchError` with `context.diffPath`.
|
|
75
|
+
3. `threshold: 0.005` = up to 0.5 % of unmasked pixels may differ
|
|
76
|
+
before the assertion fails. Tune per scene — splash screens can
|
|
77
|
+
be near-zero; menu screens with subtle gradients may need 0.01
|
|
78
|
+
or more.
|
|
79
|
+
|
|
80
|
+
## Regenerating the baseline
|
|
81
|
+
|
|
82
|
+
When an intentional UI change makes the old baseline wrong:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
UPDATE_BASELINES=1 pnpm jest tests/screenshot-home.test.ts
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The env var is **blocked in CI** (both `CI` and `UPDATE_BASELINES`
|
|
89
|
+
truthy simultaneously raises an error). Commit the regenerated PNG
|
|
90
|
+
only after a visual review.
|
|
91
|
+
|
|
92
|
+
## Common pitfalls
|
|
93
|
+
|
|
94
|
+
- **Masks are in baseline-canvas coords, not device coords.** The
|
|
95
|
+
mask is applied before diffing, so the region must align with the
|
|
96
|
+
baseline image pixels. If the baseline is HD and the device is
|
|
97
|
+
FHD, capture a fresh FHD baseline — do not scale masks by hand.
|
|
98
|
+
- **Don't raise the threshold to mute a real regression.** If the
|
|
99
|
+
diff PNG shows a genuine change, commit a new baseline instead of
|
|
100
|
+
loosening the threshold.
|
|
101
|
+
- **Avoid capturing mid-transition.** Without
|
|
102
|
+
`captureStableScreenshot`, a single-frame capture will flake.
|
|
103
|
+
- **Never commit `test-results/diffs/`.** Add it to `.gitignore`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Example: smoke test
|
|
2
|
+
|
|
3
|
+
The shortest possible end-to-end test: deploy the app, confirm the
|
|
4
|
+
launch screen rendered, send a keypress, confirm a DOM transition.
|
|
5
|
+
Use this as a template for the first test in a fresh repo.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- Jest config wired per [getting-started.md](../getting-started.md).
|
|
10
|
+
- A Roku device reachable on the LAN with `rk server` running.
|
|
11
|
+
- A sideloadable app at `apps/smoke` (or substitute your own path).
|
|
12
|
+
|
|
13
|
+
## Test file
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// tests/smoke.test.ts
|
|
17
|
+
import {
|
|
18
|
+
findNode,
|
|
19
|
+
installDeviceTestHooks
|
|
20
|
+
} from "@roku-sdk/device-test-client";
|
|
21
|
+
|
|
22
|
+
const ctx = installDeviceTestHooks();
|
|
23
|
+
|
|
24
|
+
describe("smoke", () => {
|
|
25
|
+
test("launches and responds to input", async () => {
|
|
26
|
+
const client = ctx.getClient();
|
|
27
|
+
|
|
28
|
+
await client.buildAndDeploy("apps/smoke", {
|
|
29
|
+
readyNode: { attr: { id: "home-screen" } }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const tree = await client.queryAppUi();
|
|
33
|
+
const playButton = findNode(tree, {
|
|
34
|
+
tag: "Button",
|
|
35
|
+
attr: { id: "play" }
|
|
36
|
+
});
|
|
37
|
+
expect(playButton.attrs.text).toBe("Play");
|
|
38
|
+
|
|
39
|
+
await client.keypress("Select");
|
|
40
|
+
|
|
41
|
+
await client.waitForNode(
|
|
42
|
+
{ attr: { id: "player-screen" } },
|
|
43
|
+
{ timeout: 5_000 }
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## What the test exercises
|
|
50
|
+
|
|
51
|
+
1. `installDeviceTestHooks()` wires `beforeAll` / `beforeEach` /
|
|
52
|
+
`afterEach` hooks that resolve the device IP, build the
|
|
53
|
+
`RkClient`, reset the log session per test, and collect failure
|
|
54
|
+
artifacts on uncaught errors.
|
|
55
|
+
2. `buildAndDeploy` sideloads the app and waits until `readyNode`
|
|
56
|
+
appears in the SceneGraph tree. Any crash or post-deploy system
|
|
57
|
+
dialog during readiness polling raises `DeployFailedError` /
|
|
58
|
+
`AppCrashedError`, which `installDeviceTestHooks` turns into a
|
|
59
|
+
full failure bundle (screenshot + dom + logs).
|
|
60
|
+
3. `queryAppUi` fetches the current tree; `findNode` throws
|
|
61
|
+
`NodeNotFoundError` if the predicate doesn't match.
|
|
62
|
+
4. `keypress("Select")` is ECP; subsequent `waitForNode` polls
|
|
63
|
+
`queryAppUi` on the default 200 ms cadence.
|
|
64
|
+
|
|
65
|
+
## Why this is a template, not a framework
|
|
66
|
+
|
|
67
|
+
No retries, no conditional logic, no preconditions — just four
|
|
68
|
+
actions and two assertions. Add preconditions, screenshot checks,
|
|
69
|
+
or log gates by layering them in. The other examples show how.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
This page walks you from an empty workspace to a passing integration test
|
|
4
|
+
running against a real Roku device. If you already have a Roku sideload
|
|
5
|
+
toolchain (via `rk`) you can skip straight to
|
|
6
|
+
[Step 3 — author your first test](#step-3--author-your-first-test).
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
- **Node.js 24+** — the SDK-wide supported runtime. The package needs a
|
|
11
|
+
global `fetch`; runtimes without one fail log-session construction with
|
|
12
|
+
"fetch implementation required".
|
|
13
|
+
- **Jest 29 or 30** as the test runner.
|
|
14
|
+
- A Roku device on your local network with **developer mode enabled** and
|
|
15
|
+
a known **developer password**.
|
|
16
|
+
- The `**rk` CLI** (from the `[@roku-sdk/tooling](../../tooling)` package)
|
|
17
|
+
configured via `rk device add <ip>` so `.roku/roku-dev-config.json`
|
|
18
|
+
exists next to your tests **OR** the env-var pair
|
|
19
|
+
`RK_DEVICE_IP` + `RK_DEVICE_PASSWORD` set (intended for CI — see
|
|
20
|
+
[Configuration → Device credentials](configuration.md#device-credentials)).
|
|
21
|
+
When both env vars are set the `.roku/` walk is skipped entirely. With
|
|
22
|
+
only `RK_DEVICE_IP` set, the password is taken from the `.roku/` entry
|
|
23
|
+
with that IP.
|
|
24
|
+
- `**rk server` running** on the host if you plan to use log assertions.
|
|
25
|
+
The daemon tails the Roku telnet log port (8085) and exposes an HTTP /
|
|
26
|
+
SSE front door that the SDK consumes.
|
|
27
|
+
|
|
28
|
+
Verify the device is reachable:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
rk device list # the active device should have a green dot
|
|
32
|
+
rk device ping # smoke-checks /query/device-info
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Step 1 — install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pnpm add -D @roku-sdk/device-test-client jest ts-jest @types/jest
|
|
39
|
+
# or npm/yarn equivalents
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Step 2 — wire Jest
|
|
43
|
+
|
|
44
|
+
Drop these three files next to your tests. The exact paths are examples;
|
|
45
|
+
adjust them in `jest.config.ts` if you prefer a different layout.
|
|
46
|
+
|
|
47
|
+
### `jest.config.ts`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import type { Config } from "jest";
|
|
51
|
+
|
|
52
|
+
const config: Config = {
|
|
53
|
+
preset: "ts-jest",
|
|
54
|
+
testEnvironment: "@roku-sdk/device-test-client/environment",
|
|
55
|
+
testMatch: ["<rootDir>/tests/**/*.test.ts"],
|
|
56
|
+
globalSetup: "<rootDir>/tests/support/global-setup.ts",
|
|
57
|
+
globalTeardown: "<rootDir>/tests/support/global-teardown.ts",
|
|
58
|
+
setupFilesAfterEach: ["<rootDir>/tests/support/setup.ts"],
|
|
59
|
+
maxWorkers: 1,
|
|
60
|
+
testTimeout: 60_000
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default config;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The custom `testEnvironment` is what captures per-failure bundles (see
|
|
67
|
+
[Jest integration](jest-integration.md)). `maxWorkers: 1` is required —
|
|
68
|
+
tests share a single device.
|
|
69
|
+
|
|
70
|
+
### `tests/support/global-setup.ts`
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createGlobalSetup } from "@roku-sdk/device-test-client";
|
|
74
|
+
import { resolve } from "node:path";
|
|
75
|
+
|
|
76
|
+
export default createGlobalSetup({
|
|
77
|
+
testResultsDir: resolve(__dirname, "../../test-results")
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`createGlobalSetup` resolves the active device, queries `DeviceInfo`,
|
|
82
|
+
writes `test-results/device-env.json`, and boots a fresh `rk server` log
|
|
83
|
+
session. See
|
|
84
|
+
`[createGlobalSetup](jest-integration.md#creategloballsetup)` for the full
|
|
85
|
+
contract.
|
|
86
|
+
|
|
87
|
+
### `tests/support/global-teardown.ts`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createGlobalTeardown } from "@roku-sdk/device-test-client";
|
|
91
|
+
|
|
92
|
+
export default createGlobalTeardown({ exitToHome: true });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `tests/support/setup.ts`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
createDefaultClient,
|
|
100
|
+
installDeviceTestHooks
|
|
101
|
+
} from "@roku-sdk/device-test-client";
|
|
102
|
+
import { resolve } from "node:path";
|
|
103
|
+
|
|
104
|
+
const client = createDefaultClient();
|
|
105
|
+
|
|
106
|
+
installDeviceTestHooks({
|
|
107
|
+
client,
|
|
108
|
+
baselineRoot: resolve(__dirname, "../baselines"),
|
|
109
|
+
testResultsDir: resolve(__dirname, "../../test-results"),
|
|
110
|
+
diffRoot: resolve(__dirname, "../../test-results/diffs"),
|
|
111
|
+
deviceEnvFile: resolve(
|
|
112
|
+
__dirname,
|
|
113
|
+
"../../test-results/device-env.json"
|
|
114
|
+
)
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export { client };
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`installDeviceTestHooks` is the **single** wiring entry point in v2.0.0.
|
|
121
|
+
It:
|
|
122
|
+
|
|
123
|
+
- registers the `client` so `expectTree` / `expectScreenshot` /
|
|
124
|
+
`itOnDevice` / `describeOnDevice` see the active device without extra
|
|
125
|
+
setup calls,
|
|
126
|
+
- anchors the **baselines root** (used by `expectTree` /
|
|
127
|
+
`expectScreenshot`),
|
|
128
|
+
- anchors the **screenshot diff root** (PNGs for failed
|
|
129
|
+
`expectScreenshot` runs),
|
|
130
|
+
- anchors the **device-env snapshot path** that
|
|
131
|
+
`readCachedDeviceInfoSync` (via registration-time preconditions)
|
|
132
|
+
reads,
|
|
133
|
+
- starts the log SSE session in `beforeAll`,
|
|
134
|
+
- aborts every subsequent test in a worker if the device flagged itself
|
|
135
|
+
unhealthy (fail-fast cascade),
|
|
136
|
+
- flushes per-failure bundles in the custom test environment.
|
|
137
|
+
|
|
138
|
+
> **Migrated from v1.x?** The separate `setActiveClient(...)` /
|
|
139
|
+
> `setBaselineRoot(...)` / `setScreenshotDiffRoot(...)` /
|
|
140
|
+
> `setDeviceEnvFile(...)` calls you may have in an older `setup.ts`
|
|
141
|
+
> were removed from the barrel in v2.0.0 — pass the same values as
|
|
142
|
+
> `installDeviceTestHooks` options above. See the `installDeviceTestHooks` options above for the supported setup,
|
|
143
|
+
> and [Versioning](../README.md#versioning) for the public API contract.
|
|
144
|
+
|
|
145
|
+
## Step 3 — author your first test
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// tests/smoke.test.ts
|
|
149
|
+
import { client } from "./support/setup";
|
|
150
|
+
|
|
151
|
+
test("app boots to the home scene", async () => {
|
|
152
|
+
await client.buildAndDeploy("apps/buttons", {
|
|
153
|
+
readyNode: { attr: { id: "home-title" } }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await client.keypress("Select");
|
|
157
|
+
|
|
158
|
+
const firstButton = await client.waitForNode(
|
|
159
|
+
{ focused: true, attr: { id: "first-button" } },
|
|
160
|
+
{ timeout: 3_000 }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(firstButton.attrs.text).toBe("Play");
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Run the suite:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pnpm jest
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
On success you should see:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
[device-test-client] device: Roku Ultra (4800X), firmware 14.1.0,
|
|
177
|
+
videoMode 1080p60, locale en_US
|
|
178
|
+
PASS tests/smoke.test.ts
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
On failure, look under `test-results/failures/<sanitized-test-name>/` for
|
|
182
|
+
the screenshot, DOM tree, recent logs, and error bundle — the reviewer
|
|
183
|
+
gets everything they need to diagnose without re-running.
|
|
184
|
+
|
|
185
|
+
## Where to next?
|
|
186
|
+
|
|
187
|
+
- `[RkClient` reference](rkclient.md) — every method, every option.
|
|
188
|
+
- [DOM assertions](dom-assertions.md) — `findNode`, `waitForNode`,
|
|
189
|
+
`expectTree`.
|
|
190
|
+
- [Screenshot assertions](screenshot-assertions.md) — `expectScreenshot`
|
|
191
|
+
with masks and thresholds.
|
|
192
|
+
- [Preconditions](preconditions.md) — skip tests cleanly on unsupported
|
|
193
|
+
hardware.
|
|
194
|
+
- [Examples](examples/) — end-to-end walkthroughs.
|
|
195
|
+
|