@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/docs/baselines.md +221 -0
  4. package/docs/configuration.md +258 -0
  5. package/docs/dom-assertions.md +264 -0
  6. package/docs/errors.md +525 -0
  7. package/docs/examples/deep-link-cold-start.md +85 -0
  8. package/docs/examples/focus-navigation.md +81 -0
  9. package/docs/examples/log-driven-assertion.md +99 -0
  10. package/docs/examples/precondition-skips.md +150 -0
  11. package/docs/examples/screenshot-regression.md +103 -0
  12. package/docs/examples/smoke-test.md +69 -0
  13. package/docs/getting-started.md +195 -0
  14. package/docs/jest-integration.md +331 -0
  15. package/docs/log-assertions.md +314 -0
  16. package/docs/preconditions.md +317 -0
  17. package/docs/rkclient.md +655 -0
  18. package/docs/screenshot-assertions.md +316 -0
  19. package/internal/constants.ts +54 -0
  20. package/internal/deploy.ts +60 -0
  21. package/internal/paths.ts +15 -0
  22. package/package.json +80 -3
  23. package/src/assertions/assertions.ts +661 -0
  24. package/src/assertions/baseline-env-guard.ts +43 -0
  25. package/src/assertions/baselines.ts +178 -0
  26. package/src/assertions/screenshot-artifacts.ts +39 -0
  27. package/src/assertions/screenshot-diff.ts +447 -0
  28. package/src/client/build-cache.ts +292 -0
  29. package/src/client/client.ts +1656 -0
  30. package/src/client/deploy.ts +402 -0
  31. package/src/client/ecp-queue.ts +112 -0
  32. package/src/client/ecp-retry.ts +94 -0
  33. package/src/client/factory.ts +147 -0
  34. package/src/client/health.ts +214 -0
  35. package/src/client/parsers.ts +155 -0
  36. package/src/client/server-url.ts +137 -0
  37. package/src/constants.ts +471 -0
  38. package/src/device/device-class.ts +57 -0
  39. package/src/device/device-env-sync.ts +58 -0
  40. package/src/device/device-resolver-sync.ts +43 -0
  41. package/src/device/device-resolver.ts +141 -0
  42. package/src/device/lease-provisioning.ts +54 -0
  43. package/src/errors.ts +773 -0
  44. package/src/index.ts +186 -0
  45. package/src/internal/constants.ts +3 -0
  46. package/src/jest/artifacts.ts +87 -0
  47. package/src/jest/device-test-environment.ts +420 -0
  48. package/src/jest/failure-artifacts.ts +169 -0
  49. package/src/jest/global-setup.ts +133 -0
  50. package/src/jest/global-teardown.ts +132 -0
  51. package/src/jest/install-hooks.ts +488 -0
  52. package/src/jest/jest-setup.ts +53 -0
  53. package/src/jest/preconditions.ts +239 -0
  54. package/src/jest/telemetry.ts +331 -0
  55. package/src/logs/device-log-sink.ts +69 -0
  56. package/src/logs/log-buffer.ts +265 -0
  57. package/src/logs/log-session.ts +374 -0
  58. package/src/logs/log-stream-client.ts +296 -0
  59. package/src/paths.ts +65 -0
  60. package/src/types.ts +443 -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,314 @@
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
+ - throws `WaitForLogTimeoutError` on timeout with `recentLines`,
103
+ `position`, `timeout`.
104
+
105
+ **Example.**
106
+
107
+ ```ts
108
+ const pos = client.logPosition();
109
+ await client.keypress("Select");
110
+ const line = await client.waitForLog(/item-selected: id=(\w+)/, {
111
+ since: pos,
112
+ timeout: 2_000
113
+ });
114
+
115
+ const id = /id=(\w+)/.exec(line)?.[1];
116
+ expect(id).toBe("abc-123");
117
+ ```
118
+
119
+ ## `expectNoLog`
120
+
121
+ ```ts
122
+ expectNoLog(pattern: RegExp, opts: LogQueryOptions): Promise<void>;
123
+ ```
124
+
125
+ Resolves after `opts.window` ms (default 5 000) iff no line matching
126
+ `pattern` appeared in the window. Throws `UnexpectedLogError` with the
127
+ offending `line` if one did, or `LogStreamGapError` if a gap was
128
+ detected (the missing line *might* have matched — we cannot be
129
+ certain).
130
+
131
+ **Example — no warning after a benign action.**
132
+
133
+ ```ts
134
+ const pos = client.logPosition();
135
+ await client.keypress("Down");
136
+ await client.expectNoLog(/WARN:|ERROR:/, { since: pos, window: 1_500 });
137
+ ```
138
+
139
+ ## `logs`
140
+
141
+ ```ts
142
+ logs(opts: { since: LogPosition }): Promise<LogLine[]>;
143
+ ```
144
+
145
+ Immediate snapshot of every line from `since` to the current
146
+ high-water. Throws `LogStreamGapError` if a tracked gap overlaps the
147
+ range, or `LogBufferOverflowError` if `since` is below the buffer's
148
+ `minAvailableSeq`.
149
+
150
+ **Example — post-mortem log dump.**
151
+
152
+ ```ts
153
+ const pos = client.logPosition();
154
+ await someScenario();
155
+ const lines = await client.logs({ since: pos });
156
+ console.log(`Captured ${lines.length} log lines:`);
157
+ lines.forEach(l => console.log(l));
158
+ ```
159
+
160
+ ## `ensureLogSession` / `disposeLogSession`
161
+
162
+ ```ts
163
+ ensureLogSession(): Promise<void>;
164
+ disposeLogSession(): void;
165
+ ```
166
+
167
+ `installDeviceTestHooks` calls `ensureLogSession()` once in `beforeAll`
168
+ so the first `logPosition()` in a test file is cheap. Direct callers
169
+ only need these in bespoke runners; `waitForLog` / `expectNoLog` /
170
+ `logs` all call `ensureLogSession` implicitly on first use.
171
+
172
+ `disposeLogSession` closes the SSE subscription **without** calling
173
+ `/logs/stop` — the global teardown owns the server-side lifecycle.
174
+
175
+ ## `logBuffer` / `recentLogLines`
176
+
177
+ ```ts
178
+ logBuffer(): LogBufferLike;
179
+ recentLogLines(n?: number): Array<{ seq: number; line: LogLine }>;
180
+
181
+ interface LogBufferLike {
182
+ currentPosition(): LogPosition;
183
+ linesSince(pos: LogPosition): LogLine[];
184
+ }
185
+ ```
186
+
187
+ `logBuffer()` returns the structural interface the client uses
188
+ internally — useful for custom reporters. `recentLogLines(n = 200)`
189
+ returns the last `n` entries with their seqs, which is what
190
+ `installDeviceTestHooks` uses to attach recent lines to failure
191
+ bundles.
192
+
193
+ ## Gap semantics
194
+
195
+ `LogBuffer` tracks gaps as half-open `[start, end)` ranges. A gap is
196
+ recorded whenever the SSE stream transitions through:
197
+
198
+ - `status: reconnecting` → `markGapStart()` — the next seq becomes
199
+ the gap start (lines that would-have-been).
200
+ - `status: active` **after** `reconnecting` → `markGapEnd()` — closes
201
+ at the current high-water. A zero-width boundary reconnect still
202
+ records `[start, start+1)` so assertions straddling it fail-safe.
203
+ - `status: stopped` → closes the stream locally.
204
+ - Server-initiated close (transport error) → closes any in-flight
205
+ gap at the current high-water.
206
+
207
+ ### Asymmetric rules: `waitForLog` vs `expectNoLog`
208
+
209
+ The two assertion families have different soundness requirements, and
210
+ the gap-overlap rule is correspondingly asymmetric:
211
+
212
+ - **`waitForLog`** is a **positive** assertion: "did the marker fire
213
+ post-`since`?". A clean match — one whose seq is strictly outside
214
+ every recorded gap — is direct evidence that the marker fired,
215
+ regardless of whether earlier (or later) gaps exist in the wait
216
+ window. The implementation therefore uses the per-match
217
+ `LogBuffer.findGapContaining(seq)` check rather than the broader
218
+ `hasGapWithin(since, ...)`. Gaps still matter at the timeout
219
+ boundary: if the wait runs out the clock without finding a clean
220
+ match and any gap occurred in the window, the timeout is ambiguous
221
+ (the marker might have appeared in the dropped window) and surfaces
222
+ as `LogStreamGapError`.
223
+
224
+ - **`expectNoLog`** is a **negative** assertion: "the marker did NOT
225
+ fire in the window". Absence is unprovable across a gap — a
226
+ missing line might have been the forbidden one — so any overlap of
227
+ the wait window with a tracked gap is fatal. `expectNoLog` keeps
228
+ the strict `hasGapWithin(since)` rule on initial check, on every
229
+ live append, and at end-of-window.
230
+
231
+ In short: `waitForLog` accepts clean evidence even when prior
232
+ reconnects existed; `expectNoLog` rejects on any overlapping
233
+ reconnect because its truth value is sensitive to lines we did not
234
+ see. `logs` is a bulk read with no soundness contract beyond "what
235
+ is in the buffer right now"; it does not throw on gaps.
236
+
237
+ ### Why the asymmetry is sound
238
+
239
+ `since` is captured before the action, so any seq `>= since` is
240
+ post-action by definition. For `waitForLog`, observing the marker on
241
+ *any* clean (non-boundary) post-action seq is sufficient to prove
242
+ the marker fired in response to the action. Whether the marker also
243
+ appeared earlier in a now-dropped window does not change the truth
244
+ value. The only correctness concern is **line integrity** — a line
245
+ whose seq sits inside a gap could be a partial / concatenated
246
+ fragment from the reconnect boundary — which is why those matches
247
+ are still skipped. See `LogBuffer.findGapContaining` for the
248
+ half-open inclusion rule.
249
+
250
+ ## Causality pattern
251
+
252
+ The canonical three-line pattern for proving that an action caused a
253
+ log line:
254
+
255
+ ```ts
256
+ const pos = client.logPosition(); // 1. SYNC snapshot BEFORE the action
257
+ await client.keypress("Select"); // 2. Cause
258
+ await client.waitForLog(/…/, { // 3. Observe
259
+ since: pos,
260
+ timeout: 2_000
261
+ });
262
+ ```
263
+
264
+ Why this works:
265
+
266
+ - `logPosition` is synchronous — no awaitable microtask boundary
267
+ between it and the next statement.
268
+ - `waitForLog` replays buffered lines ≥ `since` before subscribing,
269
+ so fast-emitting lines that arrived while the SDK was rescheduling
270
+ don't escape the window.
271
+
272
+ ## Errors thrown
273
+
274
+ - `WaitForLogTimeoutError` — `waitForLog` timed out. Fields:
275
+ `pattern`, `timeout`, `since`, `currentPosition`, `recentLines`.
276
+ - `UnexpectedLogError` — `expectNoLog` saw a match. Fields:
277
+ `pattern`, `line`, `seq`, `window`.
278
+ - `LogStreamGapError` — gap detected across the query range. Fields:
279
+ `gap.start`, `gap.end`, `since`, `queryEnd`, `operation`.
280
+ - `LogBufferOverflowError` — `since` below `minAvailableSeq`. Fields:
281
+ `requested`, `minAvailable`.
282
+ - `LogSessionStartError` — `POST /logs/start` or SSE attach failed
283
+ before ready. Fields: `serverUrl`, `statusCode`, `detail`.
284
+ - `AppCrashedError` — `assertAppAlive` tick during a `waitForLog`.
285
+
286
+ Full field lists and recovery advice in
287
+ [`errors.md`](errors.md).
288
+
289
+ ## Internal / deep-import only
290
+
291
+ `LogBuffer` / `LogSession` / `LogStreamClient` (plus their option
292
+ and callback types) implement the streaming pipeline described at
293
+ the top of this page. In v1.x they were re-exported from the package
294
+ root so library authors could build alternative session shapes
295
+ (persistent cross-process tails, fake wires for integration tests,
296
+ reporters driving a dedicated buffer, etc.). In v2.0.0 the barrel
297
+ trimmed them out because real consumers overwhelmingly interact with
298
+ the pipeline through `RkClient.waitForLog` / `expectNoLog` / `logs`
299
+ — the classes were tracking surface area nobody imported.
300
+
301
+ The classes still exist and are still exercised by the package's own
302
+ unit tests. The one legitimate out-of-band use case is writing a
303
+ reporter or a test of a reporter that drives a buffer without a live
304
+ daemon. Reach them via deep import:
305
+
306
+ ```ts
307
+ // DEEP IMPORTS — not covered by SemVer.
308
+ import { LogBuffer } from "@roku-sdk/device-test-client/src/logs/log-buffer";
309
+ import { LogSession } from "@roku-sdk/device-test-client/src/logs/log-session";
310
+ import { LogStreamClient } from "@roku-sdk/device-test-client/src/logs/log-stream-client";
311
+ ```
312
+
313
+ If your consumer needs a stable public path, open an issue so we can
314
+ promote a dedicated export with a SemVer-covered type.