@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,331 @@
|
|
|
1
|
+
# Jest integration
|
|
2
|
+
|
|
3
|
+
Everything the SDK needs to behave like a well-behaved Jest suite is
|
|
4
|
+
surfaced as a named export. There is no internal magic — you wire
|
|
5
|
+
`globalSetup`, `globalTeardown`, a `setupFilesAfterEnv` file, and the
|
|
6
|
+
custom `testEnvironment` explicitly. The custom environment is what
|
|
7
|
+
captures the diagnostic bundles CI reviewers love.
|
|
8
|
+
|
|
9
|
+
- [Overview](#overview)
|
|
10
|
+
- `[createGlobalSetup](#createglobalsetup)`
|
|
11
|
+
- `[createGlobalTeardown](#createglobalteardown)`
|
|
12
|
+
- `[installDeviceTestHooks](#installdevicetesthooks)`
|
|
13
|
+
- [Custom test environment](#custom-test-environment)
|
|
14
|
+
- [Failure bundle layout](#failure-bundle-layout)
|
|
15
|
+
- [Internal / deep-import only](#internal--deep-import-only)
|
|
16
|
+
- [Full `jest.config.ts` example](#full-jestconfigts-example)
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
globalSetup (once per suite, own process)
|
|
22
|
+
├─ cleanArtifacts(test-results/)
|
|
23
|
+
├─ resolveActiveDeviceIp + persist test-results/.device-ip
|
|
24
|
+
├─ queryDeviceInfo + readLeaseProvisioning
|
|
25
|
+
│ └─ writeDeviceEnvSnapshot(device-env.json)
|
|
26
|
+
└─ rk server: /logs/stop + /logs/start
|
|
27
|
+
|
|
28
|
+
worker Jest process (maxWorkers: 1)
|
|
29
|
+
├─ testEnvironment = "@roku-sdk/device-test-client/environment"
|
|
30
|
+
│ ↑ captures per-failure bundles via handleTestEvent
|
|
31
|
+
└─ setupFilesAfterEnv:
|
|
32
|
+
└─ installDeviceTestHooks({
|
|
33
|
+
client, baselineRoot, diffRoot,
|
|
34
|
+
testResultsDir, deviceEnvFile
|
|
35
|
+
})
|
|
36
|
+
├─ (sync) register active client + baseline/diff/env roots
|
|
37
|
+
├─ beforeAll: open device.log sink + ensureLogSession
|
|
38
|
+
├─ beforeEach: abort on unhealthy flag
|
|
39
|
+
└─ afterAll: close sink + disposeLogSession
|
|
40
|
+
|
|
41
|
+
globalTeardown (once per suite)
|
|
42
|
+
├─ POST /keypress/Home (opt-out via exitToHome: false)
|
|
43
|
+
└─ POST /logs/stop
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## `createGlobalSetup`
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
function createGlobalSetup(opts: GlobalSetupOptions): () => Promise<void>;
|
|
50
|
+
|
|
51
|
+
interface GlobalSetupOptions {
|
|
52
|
+
/** Absolute path to the consumer's `test-results/` directory. */
|
|
53
|
+
testResultsDir: string;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Runs once per suite, before any worker. Responsibilities:
|
|
58
|
+
|
|
59
|
+
1. Reject `UPDATE_BASELINES=1` when `CI=true` (prevents drive-by
|
|
60
|
+
baseline mutation in CI).
|
|
61
|
+
2. Clean stale artifacts in `testResultsDir` (preserves `.gitkeep`).
|
|
62
|
+
3. Resolve device IP (`RK_DEVICE_IP` env → `.roku/` active device →
|
|
63
|
+
first registered device) and persist `test-results/.device-ip`.
|
|
64
|
+
4. Query `/query/device-info` once; validate the `videoMode` against
|
|
65
|
+
`SUPPORTED_VIDEO_MODES`; write `test-results/device-env.json`, merged
|
|
66
|
+
with the provisioning provenance recorded on this job's lease (e.g.
|
|
67
|
+
`rootfsArtifactoryPath`, omitted when the pool flashes no image).
|
|
68
|
+
5. Log the one-line device diagnostic.
|
|
69
|
+
6. Reset the `rk server` log session (`POST /logs/stop` then
|
|
70
|
+
`POST /logs/start`) so every worker attaches to the same starting
|
|
71
|
+
buffer. Best-effort — a missing daemon is a warning, not a
|
|
72
|
+
failure.
|
|
73
|
+
|
|
74
|
+
**Example — `tests/support/global-setup.ts`.**
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createGlobalSetup } from "@roku-sdk/device-test-client";
|
|
78
|
+
import { resolve } from "node:path";
|
|
79
|
+
|
|
80
|
+
export default createGlobalSetup({
|
|
81
|
+
testResultsDir: resolve(__dirname, "../../test-results")
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Common pitfall.** `testResultsDir` must be **absolute** —
|
|
86
|
+
`cleanArtifacts` will delete contents relative to `process.cwd()` if
|
|
87
|
+
you forget, which can surprise you.
|
|
88
|
+
|
|
89
|
+
## `createGlobalTeardown`
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
function createGlobalTeardown(opts?: CreateGlobalTeardownOptions): () => Promise<void>;
|
|
93
|
+
|
|
94
|
+
interface CreateGlobalTeardownOptions {
|
|
95
|
+
/** Override the rk server URL. Resolution precedence matches RkClient. */
|
|
96
|
+
serverUrl?: string;
|
|
97
|
+
/** Override the device IP. Defaults to `RK_DEVICE_IP`. */
|
|
98
|
+
deviceIp?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Send `/keypress/Home` to the device before releasing the log session.
|
|
101
|
+
* Defaults to `true`. Set to `false` for manual post-run inspection.
|
|
102
|
+
*/
|
|
103
|
+
exitToHome?: boolean;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Runs once per suite, after every worker. Two jobs:
|
|
108
|
+
|
|
109
|
+
1. `POST /keypress/Home` (opt-out) so the sideloaded channel exits
|
|
110
|
+
to the home screen instead of burning the last fixture in.
|
|
111
|
+
2. `POST /logs/stop` so the daemon's connection to the device is
|
|
112
|
+
released.
|
|
113
|
+
|
|
114
|
+
Both steps are best-effort — errors are warned, not thrown. A
|
|
115
|
+
teardown that throws would mask real test failures.
|
|
116
|
+
|
|
117
|
+
**Example — `tests/support/global-teardown.ts`.**
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createGlobalTeardown } from "@roku-sdk/device-test-client";
|
|
121
|
+
|
|
122
|
+
export default createGlobalTeardown({ exitToHome: true });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## `installDeviceTestHooks`
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
function installDeviceTestHooks(opts?: InstallDeviceTestHooksOptions): void;
|
|
129
|
+
|
|
130
|
+
interface InstallDeviceTestHooksOptions {
|
|
131
|
+
/** RkClient to attach the log session against. Required if startLogSession is true. */
|
|
132
|
+
client?: RkClient;
|
|
133
|
+
/** Disable `beforeAll` log-session attach. Default `true`. */
|
|
134
|
+
startLogSession?: boolean;
|
|
135
|
+
/** Absolute path for the baselines directory (forwards to the internal `setBaselineRoot`). */
|
|
136
|
+
baselineRoot?: string;
|
|
137
|
+
/** Absolute path for screenshot diff PNGs (forwards to the internal `setScreenshotDiffRoot`). */
|
|
138
|
+
diffRoot?: string;
|
|
139
|
+
/** Absolute path to test-results/. Enables device.log + failures/<sanitized>/. */
|
|
140
|
+
testResultsDir?: string;
|
|
141
|
+
/** Absolute path to `device-env.json` (forwards to the internal `setDeviceEnvFile`). */
|
|
142
|
+
deviceEnvFile?: string;
|
|
143
|
+
/** Disable failure-bundle capture in the env. Default `true`. */
|
|
144
|
+
captureFailureBundles?: boolean;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Must be called from a `setupFilesAfterEnv` module so Jest's
|
|
149
|
+
`beforeAll` / `beforeEach` / `afterAll` globals are available. In
|
|
150
|
+
v2.0.0 this is the **only** wiring entry point — the raw setter
|
|
151
|
+
functions (`setActiveClient`, `setBaselineRoot`,
|
|
152
|
+
`setScreenshotDiffRoot`, `setDeviceEnvFile`) are no longer exported
|
|
153
|
+
from the barrel.
|
|
154
|
+
|
|
155
|
+
The hook behaves in two phases:
|
|
156
|
+
|
|
157
|
+
1. **Synchronous (at install time)** — registers the active client
|
|
158
|
+
and path roots so assertions that fire from a test file's
|
|
159
|
+
top-level code (e.g. a `describeOnDevice(...)` at module scope)
|
|
160
|
+
already see the correct wiring.
|
|
161
|
+
2. **Jest lifecycle hooks:**
|
|
162
|
+
- `**beforeAll**` — opens `device.log` sink, subscribes it to the
|
|
163
|
+
client's log buffer, calls `await client.ensureLogSession()`.
|
|
164
|
+
The sink subscription is installed **before** `ensureLogSession`
|
|
165
|
+
so the replay batch that SSE delivers on attach lands in the log
|
|
166
|
+
file.
|
|
167
|
+
- `**beforeEach**` — if `isDeviceHealthy() === false`, throws
|
|
168
|
+
`DeviceUnhealthyError` so remaining tests in the worker abort
|
|
169
|
+
fast. This is the cascade-guard that prevents one bad deploy
|
|
170
|
+
from producing 50 downstream false positives. With
|
|
171
|
+
`deviceRecovery` enabled it first probes `waitForDeviceOnline`
|
|
172
|
+
and clears the flag if the device came back — **except** when
|
|
173
|
+
the device is latched fatally wedged (`isDeviceFatal()`, e.g.
|
|
174
|
+
`never-left-home`), where it aborts immediately without probing
|
|
175
|
+
(a wedged VM still answers ECP, so probing would falsely
|
|
176
|
+
"recover" it). See [errors.md](errors.md#fatal-device-wedge).
|
|
177
|
+
- `**afterAll**` — unsubscribes the sink, closes it, calls
|
|
178
|
+
`client.disposeLogSession()`.
|
|
179
|
+
|
|
180
|
+
`diffRoot` and `testResultsDir` are also exposed to the custom test
|
|
181
|
+
environment via module-global keys so `DeviceTestEnvironment` can
|
|
182
|
+
locate `<root>/failures/` without re-reading your Jest config.
|
|
183
|
+
|
|
184
|
+
**Example — `tests/support/setup.ts`.**
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import {
|
|
188
|
+
createDefaultClient,
|
|
189
|
+
installDeviceTestHooks
|
|
190
|
+
} from "@roku-sdk/device-test-client";
|
|
191
|
+
import { resolve } from "node:path";
|
|
192
|
+
|
|
193
|
+
const client = createDefaultClient();
|
|
194
|
+
|
|
195
|
+
installDeviceTestHooks({
|
|
196
|
+
client,
|
|
197
|
+
baselineRoot: resolve(__dirname, "../baselines"),
|
|
198
|
+
testResultsDir: resolve(__dirname, "../../test-results"),
|
|
199
|
+
diffRoot: resolve(__dirname, "../../test-results/diffs"),
|
|
200
|
+
deviceEnvFile: resolve(
|
|
201
|
+
__dirname,
|
|
202
|
+
"../../test-results/device-env.json"
|
|
203
|
+
)
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export { client };
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Custom test environment
|
|
210
|
+
|
|
211
|
+
The package ships a Jest test environment that subclasses
|
|
212
|
+
`jest-environment-node` and hooks `handleTestEvent`. It captures the
|
|
213
|
+
device-side snapshot eagerly on `test_fn_failure` / `hook_failure`
|
|
214
|
+
(so subsequent `buildAndDeploy` calls don't overwrite the state you
|
|
215
|
+
wanted) and flushes to disk on `teardown` (once per test file).
|
|
216
|
+
|
|
217
|
+
Register it in your Jest config:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// jest.config.ts
|
|
221
|
+
export default {
|
|
222
|
+
testEnvironment: "@roku-sdk/device-test-client/environment"
|
|
223
|
+
// ...
|
|
224
|
+
};
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The environment is a no-op unless `installDeviceTestHooks` has
|
|
228
|
+
exposed its globals (the client + `testResultsDir`), so it's safe to
|
|
229
|
+
keep configured even in suites that don't use it.
|
|
230
|
+
|
|
231
|
+
## Failure bundle layout
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
test-results/
|
|
235
|
+
├── device.log # every line, one run, with header
|
|
236
|
+
├── device-env.json # DeviceInfo snapshot + lease provenance
|
|
237
|
+
├── .device-ip # resolved IP (worker handoff)
|
|
238
|
+
├── diffs/
|
|
239
|
+
│ └── home--initial.diff.png # screenshot assertion failures
|
|
240
|
+
└── failures/
|
|
241
|
+
└── <sanitized-test-path>/
|
|
242
|
+
├── screenshot.png # captureScreenshot() at failure
|
|
243
|
+
├── dom-tree.json # queryAppUi() at failure (2-space indent)
|
|
244
|
+
├── recent-logs.txt # last N entries, `<seq>\t<line>`
|
|
245
|
+
└── error.txt # stack trace preferred
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Each artifact is **best-effort** — a permissions error on
|
|
249
|
+
`screenshot.png` doesn't block `error.txt`. A bundle that carries
|
|
250
|
+
only `error.txt` is still useful ("test failed; no device state
|
|
251
|
+
captured").
|
|
252
|
+
|
|
253
|
+
## Internal / deep-import only
|
|
254
|
+
|
|
255
|
+
The helpers below are used by `createGlobalSetup` /
|
|
256
|
+
`createGlobalTeardown` / `installDeviceTestHooks` /
|
|
257
|
+
`DeviceTestEnvironment` internally, and `installDeviceTestHooks` is
|
|
258
|
+
the documented way to configure them. They are **not** exported from
|
|
259
|
+
the v2.0.0 barrel (`@roku-sdk/device-test-client`) but remain
|
|
260
|
+
reachable as deep imports (e.g.
|
|
261
|
+
`@roku-sdk/device-test-client/src/jest/artifacts`). Deep paths are
|
|
262
|
+
**not** covered by the SemVer contract — anything below may move or
|
|
263
|
+
change shape in a minor release without a major bump. If you find
|
|
264
|
+
yourself needing one, open an issue so we can promote a proper
|
|
265
|
+
public entry point.
|
|
266
|
+
|
|
267
|
+
Grouped by feature (see the source files listed for signatures):
|
|
268
|
+
|
|
269
|
+
- **Failure-bundle writers** — `sanitizeTestPath`,
|
|
270
|
+
`ensureFailuresDir`, `writeFailureBundle` (src/jest/artifacts).
|
|
271
|
+
Used by the custom test environment to lay down
|
|
272
|
+
`test-results/failures/<name>/{screenshot.png,dom-tree.json,recent-logs.txt,error.txt}`
|
|
273
|
+
on each failing test. Exposed for bespoke reporters running outside
|
|
274
|
+
`DeviceTestEnvironment`.
|
|
275
|
+
- **`device.log` sink + log-buffer attach** — `openDeviceLogSink`,
|
|
276
|
+
`attachToBuffer`, `DeviceLogSink`, `LogBuffer` /
|
|
277
|
+
`LogBufferOptions` / `LogBufferLike` (src/logs/*). Streams every
|
|
278
|
+
observed log line to `<testResultsDir>/device.log`.
|
|
279
|
+
`installDeviceTestHooks` drives this automatically when
|
|
280
|
+
`testResultsDir` is provided; direct use is only needed for custom
|
|
281
|
+
runners.
|
|
282
|
+
- **Device-IP handoff** — `resolveActiveDeviceIp`,
|
|
283
|
+
`persistDeviceIp` (src/device/*). `createGlobalSetup` calls these;
|
|
284
|
+
bespoke setup scripts can reuse them.
|
|
285
|
+
- **Device-env snapshot (sync reader)** —
|
|
286
|
+
`readCachedDeviceInfoSync`, `getDeviceEnvFile`, `setDeviceEnvFile`,
|
|
287
|
+
`resetDeviceEnvFileOverride` (src/device/device-env-sync). The
|
|
288
|
+
synchronous door into `test-results/device-env.json` that
|
|
289
|
+
`itOnDevice` / `describeOnDevice` use at registration time
|
|
290
|
+
(they cannot await). `setDeviceEnvFile` is now configured via
|
|
291
|
+
`installDeviceTestHooks({ deviceEnvFile })` at the public layer.
|
|
292
|
+
- **Artifact helpers for custom globalSetup** — `cleanArtifacts`,
|
|
293
|
+
`assertSupportedVideoMode`, `writeDeviceEnvSnapshot`,
|
|
294
|
+
`logDeviceDiagnostic`, `assertBaselineUpdatesAllowed`, `isCi`
|
|
295
|
+
(src/jest/artifacts). Bundled under the one-shot
|
|
296
|
+
`createGlobalSetup` for normal consumers; exposed for users who
|
|
297
|
+
need to hand-roll a globalSetup with slightly different semantics.
|
|
298
|
+
|
|
299
|
+
**Single-writer guarantee.** `maxWorkers: 1` is required — the
|
|
300
|
+
`device.log` sink does not serialise across workers. Per-worker file
|
|
301
|
+
naming is the consumer's problem to solve if the pin is ever
|
|
302
|
+
removed.
|
|
303
|
+
|
|
304
|
+
## Full `jest.config.ts` example
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import type { Config } from "jest";
|
|
308
|
+
|
|
309
|
+
const config: Config = {
|
|
310
|
+
preset: "ts-jest",
|
|
311
|
+
testEnvironment: "@roku-sdk/device-test-client/environment",
|
|
312
|
+
testMatch: ["<rootDir>/tests/**/*.test.ts"],
|
|
313
|
+
globalSetup: "<rootDir>/tests/support/global-setup.ts",
|
|
314
|
+
globalTeardown: "<rootDir>/tests/support/global-teardown.ts",
|
|
315
|
+
setupFilesAfterEach: ["<rootDir>/tests/support/setup.ts"],
|
|
316
|
+
maxWorkers: 1,
|
|
317
|
+
testTimeout: 60_000,
|
|
318
|
+
reporters: ["default"]
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export default config;
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
With the four support files from this page plus at least one
|
|
325
|
+
`.test.ts` using `createDefaultClient()`, `pnpm jest` will:
|
|
326
|
+
|
|
327
|
+
- boot the session, snapshot the device, stream the log tail;
|
|
328
|
+
- on each test run, fail fast if the device went unhealthy earlier;
|
|
329
|
+
- on every failure, write `test-results/failures/<name>/{screenshot.png, dom-tree.json, recent-logs.txt, error.txt}`;
|
|
330
|
+
- on exit, send `Home` and stop the log session.
|
|
331
|
+
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Log assertions
|
|
2
|
+
|
|
3
|
+
Log assertions let tests gate on lines the device emitted to its
|
|
4
|
+
telnet log port (8085). The SDK captures those lines via a host-side
|
|
5
|
+
daemon, buffers them with sequence numbers, and exposes four
|
|
6
|
+
primitives: **`logPosition`** (cursor), **`waitForLog`** (positive
|
|
7
|
+
assertion with timeout), **`expectNoLog`** (negative assertion over a
|
|
8
|
+
window), and **`logs`** (bulk read). Gaps caused by SSE reconnects
|
|
9
|
+
are tracked explicitly so tests cannot silently pass on an incomplete
|
|
10
|
+
stream.
|
|
11
|
+
|
|
12
|
+
- [The streaming pipeline](#the-streaming-pipeline)
|
|
13
|
+
- [Core types](#core-types)
|
|
14
|
+
- [`logPosition`](#logposition)
|
|
15
|
+
- [`waitForLog`](#waitforlog)
|
|
16
|
+
- [`expectNoLog`](#expectnolog)
|
|
17
|
+
- [`logs`](#logs)
|
|
18
|
+
- [`ensureLogSession` / `disposeLogSession`](#ensurelogsession--disposelogsession)
|
|
19
|
+
- [`logBuffer` / `recentLogLines`](#logbuffer--recentloglines)
|
|
20
|
+
- [Gap semantics](#gap-semantics)
|
|
21
|
+
- [Causality pattern](#causality-pattern)
|
|
22
|
+
- [Errors thrown](#errors-thrown)
|
|
23
|
+
- [Internal / deep-import only](#internal--deep-import-only)
|
|
24
|
+
|
|
25
|
+
## The streaming pipeline
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Roku device ──(telnet 8085)──► rk server ──(POST /logs/start + SSE /logs/tail)──► LogSession ──► LogBuffer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
1. `rk server` owns the single TCP tail on port 8085.
|
|
32
|
+
2. `LogSession` `POST`s `/logs/start` on first use (idempotent —
|
|
33
|
+
`400 "already active"` is success), then opens the SSE subscription
|
|
34
|
+
to `/logs/tail`.
|
|
35
|
+
3. `LogStreamClient` parses `event: log | status | error` frames and
|
|
36
|
+
forwards them to callbacks on `LogSession`.
|
|
37
|
+
4. `LogSession` appends `log` payloads to `LogBuffer` and drives gap
|
|
38
|
+
tracking from `status: reconnecting / active / stopped`.
|
|
39
|
+
5. Tests read from `LogBuffer` via the `RkClient` log API.
|
|
40
|
+
|
|
41
|
+
The daemon is required for log assertions only — DOM and screenshot
|
|
42
|
+
assertions work without it.
|
|
43
|
+
|
|
44
|
+
## Core types
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
type LogPosition = string; // opaque non-negative decimal seq
|
|
48
|
+
type LogLine = string;
|
|
49
|
+
|
|
50
|
+
interface LogQueryOptions {
|
|
51
|
+
/** Cursor from `client.logPosition()` — inclusive lower bound. Required. */
|
|
52
|
+
since: LogPosition;
|
|
53
|
+
/** `waitForLog`: max wait (ms). Default `WAIT_FOR_LOG_DEFAULT_TIMEOUT_MS` (5 000). */
|
|
54
|
+
timeout?: number;
|
|
55
|
+
/** `expectNoLog`: window duration (ms). Alias for `timeout`. Default 5 000. */
|
|
56
|
+
window?: number;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## `logPosition`
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
logPosition(): LogPosition;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Synchronous.** Returns the current high-water seq as an opaque
|
|
67
|
+
decimal string. Use it *immediately before* the action whose log
|
|
68
|
+
output you want to observe.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const pos = client.logPosition();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Common pitfall.** Grabbing `logPosition` *after* a `keypress` means
|
|
75
|
+
the log line you're trying to wait for may already have arrived —
|
|
76
|
+
your `since` baseline would skip past it. Take the position first.
|
|
77
|
+
|
|
78
|
+
## `waitForLog`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
waitForLog(pattern: RegExp, opts: LogQueryOptions): Promise<LogLine>;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Resolves with the first line matching `pattern` whose seq >=
|
|
85
|
+
`opts.since` within `opts.timeout` ms. The method:
|
|
86
|
+
|
|
87
|
+
- subscribes to `LogBuffer` appends synchronously — no polling gap
|
|
88
|
+
between the promise return and arriving lines;
|
|
89
|
+
- replays any already-buffered lines >= `since` first;
|
|
90
|
+
- calls `client.assertAppAlive()` on each tick so a crash during the
|
|
91
|
+
wait surfaces as `AppCrashedError`, not a timeout;
|
|
92
|
+
- accepts a match whose seq is **strictly outside** every recorded
|
|
93
|
+
gap (line-integrity relaxation — see [Gap semantics](#gap-semantics)
|
|
94
|
+
below);
|
|
95
|
+
- skips matches whose seq is inside a gap (the line could have
|
|
96
|
+
straddled a reconnect boundary and be malformed) but does **not**
|
|
97
|
+
abort the wait — a later clean line may still satisfy the
|
|
98
|
+
assertion before the timeout;
|
|
99
|
+
- throws `LogStreamGapError` on timeout if any gap was recorded in
|
|
100
|
+
the wait window — the missing lines might have included the
|
|
101
|
+
marker, so the timeout is ambiguous, not a clean negative;
|
|
102
|
+
- re-attaches the stream once, and waits a further
|
|
103
|
+
`LOG_STREAM_RESYNC_GRACE_MS`, when the window received no line at
|
|
104
|
+
all (see [Silent windows](#silent-windows));
|
|
105
|
+
- throws `WaitForLogTimeoutError` on timeout with `recentLines`,
|
|
106
|
+
`position`, `timeout`.
|
|
107
|
+
|
|
108
|
+
**Example.**
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const pos = client.logPosition();
|
|
112
|
+
await client.keypress("Select");
|
|
113
|
+
const line = await client.waitForLog(/item-selected: id=(\w+)/, {
|
|
114
|
+
since: pos,
|
|
115
|
+
timeout: 2_000
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const id = /id=(\w+)/.exec(line)?.[1];
|
|
119
|
+
expect(id).toBe("abc-123");
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## `expectNoLog`
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
expectNoLog(pattern: RegExp, opts: LogQueryOptions): Promise<void>;
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Resolves after `opts.window` ms (default 5 000) iff no line matching
|
|
129
|
+
`pattern` appeared in the window. Throws `UnexpectedLogError` with the
|
|
130
|
+
offending `line` if one did, or `LogStreamGapError` if a gap was
|
|
131
|
+
detected (the missing line *might* have matched — we cannot be
|
|
132
|
+
certain).
|
|
133
|
+
|
|
134
|
+
**Example — no warning after a benign action.**
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const pos = client.logPosition();
|
|
138
|
+
await client.keypress("Down");
|
|
139
|
+
await client.expectNoLog(/WARN:|ERROR:/, { since: pos, window: 1_500 });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## `logs`
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
logs(opts: { since: LogPosition }): Promise<LogLine[]>;
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Immediate snapshot of every line from `since` to the current
|
|
149
|
+
high-water. Throws `LogStreamGapError` if a tracked gap overlaps the
|
|
150
|
+
range, or `LogBufferOverflowError` if `since` is below the buffer's
|
|
151
|
+
`minAvailableSeq`.
|
|
152
|
+
|
|
153
|
+
**Example — post-mortem log dump.**
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const pos = client.logPosition();
|
|
157
|
+
await someScenario();
|
|
158
|
+
const lines = await client.logs({ since: pos });
|
|
159
|
+
console.log(`Captured ${lines.length} log lines:`);
|
|
160
|
+
lines.forEach(l => console.log(l));
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## `ensureLogSession` / `disposeLogSession`
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
ensureLogSession(): Promise<void>;
|
|
167
|
+
disposeLogSession(): void;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`installDeviceTestHooks` calls `ensureLogSession()` once in `beforeAll`
|
|
171
|
+
so the first `logPosition()` in a test file is cheap. Direct callers
|
|
172
|
+
only need these in bespoke runners; `waitForLog` / `expectNoLog` /
|
|
173
|
+
`logs` all call `ensureLogSession` implicitly on first use.
|
|
174
|
+
|
|
175
|
+
`disposeLogSession` closes the SSE subscription **without** calling
|
|
176
|
+
`/logs/stop` — the global teardown owns the server-side lifecycle.
|
|
177
|
+
|
|
178
|
+
## `logBuffer` / `recentLogLines`
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
logBuffer(): LogBufferLike;
|
|
182
|
+
recentLogLines(n?: number): Array<{ seq: number; line: LogLine }>;
|
|
183
|
+
|
|
184
|
+
interface LogBufferLike {
|
|
185
|
+
currentPosition(): LogPosition;
|
|
186
|
+
linesSince(pos: LogPosition): LogLine[];
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`logBuffer()` returns the structural interface the client uses
|
|
191
|
+
internally — useful for custom reporters. `recentLogLines(n = 200)`
|
|
192
|
+
returns the last `n` entries with their seqs, which is what
|
|
193
|
+
`installDeviceTestHooks` uses to attach recent lines to failure
|
|
194
|
+
bundles.
|
|
195
|
+
|
|
196
|
+
## Gap semantics
|
|
197
|
+
|
|
198
|
+
`LogBuffer` tracks gaps as half-open `[start, end)` ranges. A gap is
|
|
199
|
+
recorded whenever the SSE stream transitions through:
|
|
200
|
+
|
|
201
|
+
- `status: reconnecting` → `markGapStart()` — the next seq becomes
|
|
202
|
+
the gap start (lines that would-have-been).
|
|
203
|
+
- `status: active` **after** `reconnecting` → `markGapEnd()` — closes
|
|
204
|
+
at the current high-water. A zero-width boundary reconnect still
|
|
205
|
+
records `[start, start+1)` so assertions straddling it fail-safe.
|
|
206
|
+
- `status: stopped` → closes the stream locally.
|
|
207
|
+
- Server-initiated close (transport error) → closes any in-flight
|
|
208
|
+
gap at the current high-water.
|
|
209
|
+
|
|
210
|
+
### Asymmetric rules: `waitForLog` vs `expectNoLog`
|
|
211
|
+
|
|
212
|
+
The two assertion families have different soundness requirements, and
|
|
213
|
+
the gap-overlap rule is correspondingly asymmetric:
|
|
214
|
+
|
|
215
|
+
- **`waitForLog`** is a **positive** assertion: "did the marker fire
|
|
216
|
+
post-`since`?". A clean match — one whose seq is strictly outside
|
|
217
|
+
every recorded gap — is direct evidence that the marker fired,
|
|
218
|
+
regardless of whether earlier (or later) gaps exist in the wait
|
|
219
|
+
window. The implementation therefore uses the per-match
|
|
220
|
+
`LogBuffer.findGapContaining(seq)` check rather than the broader
|
|
221
|
+
`hasGapWithin(since, ...)`. Gaps still matter at the timeout
|
|
222
|
+
boundary: if the wait runs out the clock without finding a clean
|
|
223
|
+
match and any gap occurred in the window, the timeout is ambiguous
|
|
224
|
+
(the marker might have appeared in the dropped window) and surfaces
|
|
225
|
+
as `LogStreamGapError`.
|
|
226
|
+
|
|
227
|
+
- **`expectNoLog`** is a **negative** assertion: "the marker did NOT
|
|
228
|
+
fire in the window". Absence is unprovable across a gap — a
|
|
229
|
+
missing line might have been the forbidden one — so any overlap of
|
|
230
|
+
the wait window with a tracked gap is fatal. `expectNoLog` keeps
|
|
231
|
+
the strict `hasGapWithin(since)` rule on initial check, on every
|
|
232
|
+
live append, and at end-of-window.
|
|
233
|
+
|
|
234
|
+
In short: `waitForLog` accepts clean evidence even when prior
|
|
235
|
+
reconnects existed; `expectNoLog` rejects on any overlapping
|
|
236
|
+
reconnect because its truth value is sensitive to lines we did not
|
|
237
|
+
see. `logs` is a bulk read with no soundness contract beyond "what
|
|
238
|
+
is in the buffer right now"; it does not throw on gaps.
|
|
239
|
+
|
|
240
|
+
### Silent windows
|
|
241
|
+
|
|
242
|
+
Gaps only describe reconnects the stream reported. A subscription can
|
|
243
|
+
also stop delivering while both ends still consider it attached — the
|
|
244
|
+
server's heartbeat probes the device over ECP, which keeps answering
|
|
245
|
+
— and then nothing is reported, nothing is closed, and no gap is
|
|
246
|
+
recorded. From inside a wait that is indistinguishable from a device
|
|
247
|
+
with nothing to say.
|
|
248
|
+
|
|
249
|
+
`waitForLog` treats a window in which **no line arrived at all** as
|
|
250
|
+
exactly that: no evidence either way. Before calling the marker
|
|
251
|
+
absent it asks `LogSession.resync()` for a fresh subscription and
|
|
252
|
+
waits `LOG_STREAM_RESYNC_GRACE_MS` longer. `/logs/tail` replays the
|
|
253
|
+
whole server-side buffer on attach and `lastServerSeq` drops the
|
|
254
|
+
duplicates, so a line only the subscription leg lost arrives through
|
|
255
|
+
the still-live subscription and resolves the wait. Nothing is
|
|
256
|
+
recorded as a gap — a replay fills the window rather than losing it,
|
|
257
|
+
and an open gap would mark the replayed lines themselves suspect.
|
|
258
|
+
|
|
259
|
+
If the re-attach also delivers nothing, the timeout stands. What that
|
|
260
|
+
rules out is a line lost between the server and this process, which
|
|
261
|
+
the replay would have carried; what it leaves is an app with nothing
|
|
262
|
+
to log in those few seconds and a stream stalled at its far end,
|
|
263
|
+
which are indistinguishable from here. A short window around a quiet
|
|
264
|
+
moment is much the commoner of the two, so the message names both
|
|
265
|
+
rather than reporting a transport fault. The re-attach is kept
|
|
266
|
+
regardless, so the next assertion in the file starts on a live
|
|
267
|
+
subscription instead of inheriting the silence.
|
|
268
|
+
|
|
269
|
+
`expectNoLog` does not yet do this, and a silent stream therefore
|
|
270
|
+
satisfies it vacuously.
|
|
271
|
+
|
|
272
|
+
### Why the asymmetry is sound
|
|
273
|
+
|
|
274
|
+
`since` is captured before the action, so any seq `>= since` is
|
|
275
|
+
post-action by definition. For `waitForLog`, observing the marker on
|
|
276
|
+
*any* clean (non-boundary) post-action seq is sufficient to prove
|
|
277
|
+
the marker fired in response to the action. Whether the marker also
|
|
278
|
+
appeared earlier in a now-dropped window does not change the truth
|
|
279
|
+
value. The only correctness concern is **line integrity** — a line
|
|
280
|
+
whose seq sits inside a gap could be a partial / concatenated
|
|
281
|
+
fragment from the reconnect boundary — which is why those matches
|
|
282
|
+
are still skipped. See `LogBuffer.findGapContaining` for the
|
|
283
|
+
half-open inclusion rule.
|
|
284
|
+
|
|
285
|
+
## Causality pattern
|
|
286
|
+
|
|
287
|
+
The canonical three-line pattern for proving that an action caused a
|
|
288
|
+
log line:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const pos = client.logPosition(); // 1. SYNC snapshot BEFORE the action
|
|
292
|
+
await client.keypress("Select"); // 2. Cause
|
|
293
|
+
await client.waitForLog(/…/, { // 3. Observe
|
|
294
|
+
since: pos,
|
|
295
|
+
timeout: 2_000
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Why this works:
|
|
300
|
+
|
|
301
|
+
- `logPosition` is synchronous — no awaitable microtask boundary
|
|
302
|
+
between it and the next statement.
|
|
303
|
+
- `waitForLog` replays buffered lines ≥ `since` before subscribing,
|
|
304
|
+
so fast-emitting lines that arrived while the SDK was rescheduling
|
|
305
|
+
don't escape the window.
|
|
306
|
+
|
|
307
|
+
## Errors thrown
|
|
308
|
+
|
|
309
|
+
- `WaitForLogTimeoutError` — `waitForLog` timed out. Fields:
|
|
310
|
+
`pattern`, `timeout`, `since`, `currentPosition`, `recentLines`.
|
|
311
|
+
- `UnexpectedLogError` — `expectNoLog` saw a match. Fields:
|
|
312
|
+
`pattern`, `line`, `seq`, `window`.
|
|
313
|
+
- `LogStreamGapError` — gap detected across the query range. Fields:
|
|
314
|
+
`gap.start`, `gap.end`, `since`, `queryEnd`, `operation`.
|
|
315
|
+
- `LogBufferOverflowError` — `since` below `minAvailableSeq`. Fields:
|
|
316
|
+
`requested`, `minAvailable`.
|
|
317
|
+
- `LogSessionStartError` — `POST /logs/start` or SSE attach failed
|
|
318
|
+
before ready. Fields: `serverUrl`, `statusCode`, `detail`.
|
|
319
|
+
- `AppCrashedError` — `assertAppAlive` tick during a `waitForLog`.
|
|
320
|
+
|
|
321
|
+
Full field lists and recovery advice in
|
|
322
|
+
[`errors.md`](errors.md).
|
|
323
|
+
|
|
324
|
+
## Internal / deep-import only
|
|
325
|
+
|
|
326
|
+
`LogBuffer` / `LogSession` / `LogStreamClient` (plus their option
|
|
327
|
+
and callback types) implement the streaming pipeline described at
|
|
328
|
+
the top of this page. In v1.x they were re-exported from the package
|
|
329
|
+
root so library authors could build alternative session shapes
|
|
330
|
+
(persistent cross-process tails, fake wires for integration tests,
|
|
331
|
+
reporters driving a dedicated buffer, etc.). In v2.0.0 the barrel
|
|
332
|
+
trimmed them out because real consumers overwhelmingly interact with
|
|
333
|
+
the pipeline through `RkClient.waitForLog` / `expectNoLog` / `logs`
|
|
334
|
+
— the classes were tracking surface area nobody imported.
|
|
335
|
+
|
|
336
|
+
The classes still exist and are still exercised by the package's own
|
|
337
|
+
unit tests. The one legitimate out-of-band use case is writing a
|
|
338
|
+
reporter or a test of a reporter that drives a buffer without a live
|
|
339
|
+
daemon. Reach them via deep import:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
// DEEP IMPORTS — not covered by SemVer.
|
|
343
|
+
import { LogBuffer } from "@roku-sdk/device-test-client/src/logs/log-buffer";
|
|
344
|
+
import { LogSession } from "@roku-sdk/device-test-client/src/logs/log-session";
|
|
345
|
+
import { LogStreamClient } from "@roku-sdk/device-test-client/src/logs/log-stream-client";
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
If your consumer needs a stable public path, open an issue so we can
|
|
349
|
+
promote a dedicated export with a SemVer-covered type.
|