@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.
Files changed (61) hide show
  1. package/BREAKING_CHANGES.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -2
  4. package/docs/baselines.md +221 -0
  5. package/docs/configuration.md +258 -0
  6. package/docs/dom-assertions.md +264 -0
  7. package/docs/errors.md +544 -0
  8. package/docs/examples/deep-link-cold-start.md +85 -0
  9. package/docs/examples/focus-navigation.md +81 -0
  10. package/docs/examples/log-driven-assertion.md +99 -0
  11. package/docs/examples/precondition-skips.md +150 -0
  12. package/docs/examples/screenshot-regression.md +103 -0
  13. package/docs/examples/smoke-test.md +69 -0
  14. package/docs/getting-started.md +195 -0
  15. package/docs/jest-integration.md +331 -0
  16. package/docs/log-assertions.md +349 -0
  17. package/docs/preconditions.md +317 -0
  18. package/docs/rkclient.md +657 -0
  19. package/docs/screenshot-assertions.md +316 -0
  20. package/package.json +81 -3
  21. package/src/assertions/assertions.ts +661 -0
  22. package/src/assertions/baseline-env-guard.ts +43 -0
  23. package/src/assertions/baselines.ts +178 -0
  24. package/src/assertions/screenshot-artifacts.ts +39 -0
  25. package/src/assertions/screenshot-diff.ts +447 -0
  26. package/src/client/build-cache.ts +292 -0
  27. package/src/client/client.ts +1964 -0
  28. package/src/client/deploy.ts +407 -0
  29. package/src/client/ecp-queue.ts +112 -0
  30. package/src/client/ecp-retry.ts +94 -0
  31. package/src/client/factory.ts +170 -0
  32. package/src/client/health.ts +345 -0
  33. package/src/client/parsers.ts +162 -0
  34. package/src/client/server-url.ts +137 -0
  35. package/src/constants.ts +520 -0
  36. package/src/device/device-class.ts +57 -0
  37. package/src/device/device-env-sync.ts +58 -0
  38. package/src/device/device-resolver-sync.ts +43 -0
  39. package/src/device/device-resolver.ts +141 -0
  40. package/src/device/lease-provisioning.ts +54 -0
  41. package/src/errors.ts +774 -0
  42. package/src/index.ts +181 -0
  43. package/src/internal/constants.ts +3 -0
  44. package/src/jest/artifacts.ts +87 -0
  45. package/src/jest/device-test-environment.ts +420 -0
  46. package/src/jest/failure-artifacts.ts +169 -0
  47. package/src/jest/global-setup.ts +137 -0
  48. package/src/jest/global-teardown.ts +132 -0
  49. package/src/jest/install-hooks.ts +490 -0
  50. package/src/jest/jest-setup.ts +53 -0
  51. package/src/jest/preconditions.ts +239 -0
  52. package/src/jest/telemetry.ts +331 -0
  53. package/src/logs/device-log-sink.ts +69 -0
  54. package/src/logs/log-buffer.ts +278 -0
  55. package/src/logs/log-session.ts +436 -0
  56. package/src/logs/log-stream-client.ts +296 -0
  57. package/src/paths.ts +65 -0
  58. package/src/types.ts +472 -0
  59. package/support/constants.ts +17 -0
  60. package/support/deploy.ts +27 -0
  61. package/support/paths.ts +13 -0
@@ -0,0 +1,436 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Host-side coordinator between `RkClient`'s log-assertion API and
5
+ * the `rk server` daemon.
6
+ *
7
+ * - Lazy `POST /logs/start` on first use; 400 "already active" is
8
+ * treated as attaching to the existing session.
9
+ * - Owns the SSE `LogStreamClient`, wiring `log` frames into the
10
+ * buffer and `status` frames into gap tracking.
11
+ * - `dispose()` closes the SSE client only; never `/logs/stop`
12
+ * (global teardown owns server-side lifecycle).
13
+ *
14
+ * `streamFactory` and `fetchImpl` are DI seams for tests.
15
+ */
16
+
17
+ import { LogSessionStartError } from "../errors";
18
+ import { LogBuffer } from "./log-buffer";
19
+ import type { LogStreamCallbacks, LogStreamClientOptions } from "./log-stream-client";
20
+ import { LogStreamClient } from "./log-stream-client";
21
+
22
+ export interface LogSessionOptions {
23
+ /** Base URL of the `rk server` daemon. */
24
+ serverUrl: string;
25
+ /** Device selector (ip / id / name). Optional; server falls back to active device. */
26
+ device?: string;
27
+ /** Roku log port. Optional; server defaults to `ROKU_LOG_PORT`. */
28
+ port?: number;
29
+ /** Requested server-side buffer size; propagated verbatim to `/logs/start`. */
30
+ bufferSize?: number;
31
+ /**
32
+ * Optional pre-built buffer. Tests inject one to pre-seed
33
+ * entries before `ensureStarted()`. Production callers let the
34
+ * session construct its own.
35
+ */
36
+ buffer?: LogBuffer;
37
+ /**
38
+ * Stream factory — defaults to `new LogStreamClient(...)`. Tests
39
+ * supply a fake so the session can be driven without HTTP.
40
+ */
41
+ streamFactory?: (opts: LogStreamClientOptions, cbs: LogStreamCallbacks) => LogStreamClientLike;
42
+ /**
43
+ * fetch override. Defaults to `globalThis.fetch`. Tests inject a
44
+ * mock to simulate the `/logs/start` response shape without opening
45
+ * real sockets.
46
+ */
47
+ fetchImpl?: typeof fetch;
48
+ }
49
+
50
+ /** Structural shape of the stream client the session drives. */
51
+ export interface LogStreamClientLike {
52
+ open(): void;
53
+ close(): void;
54
+ }
55
+
56
+ export class LogSession {
57
+ public readonly buffer: LogBuffer;
58
+ private readonly opts: LogSessionOptions;
59
+ private readonly streamFactory: NonNullable<LogSessionOptions["streamFactory"]>;
60
+ private readonly fetchImpl: typeof fetch;
61
+ private stream: LogStreamClientLike | null = null;
62
+ private startPromise: Promise<void> | null = null;
63
+ // Only close a gap on `active` preceded by `reconnecting`; the
64
+ // very first `active` on initial attach must not close a
65
+ // non-existent gap.
66
+ private lastStatus: string | null = null;
67
+ // Highest server-assigned seq observed. Dedups the replay batch
68
+ // the server emits on every attach (initial + re-attach). Init
69
+ // -1 so the first frame (seq 0) is accepted. Lines without
70
+ // serverSeq bypass the check.
71
+ private lastServerSeq = -1;
72
+ // Warn at most once per session for post-ready SSE errors so
73
+ // a server burst doesn't flood CI logs.
74
+ private postReadyErrorWarned = false;
75
+ // In-flight `resync()`, shared by concurrent callers.
76
+ private resyncPromise: Promise<void> | null = null;
77
+ // Bumped per `dispose()`, so a re-attach in flight can tell that
78
+ // the caller gave up on the subscription while it was opening.
79
+ private disposeCount = 0;
80
+ // Set by `dispose()`, cleared by an explicit `ensureStarted()`. While
81
+ // set there is no subscription to re-attach, so `resync()` is a no-op.
82
+ private disposed = false;
83
+
84
+ constructor(opts: LogSessionOptions) {
85
+ this.opts = opts;
86
+ this.buffer = opts.buffer ?? new LogBuffer();
87
+ this.streamFactory = opts.streamFactory ?? ((streamOpts, cbs) => new LogStreamClient(streamOpts, cbs));
88
+ // Friendly diagnostic instead of "fetch is not a function".
89
+ const defaultFetch = (globalThis as { fetch?: typeof fetch }).fetch;
90
+ if (!opts.fetchImpl && !defaultFetch) {
91
+ throw new Error("LogSession requires a fetch implementation. Upgrade to Node 18+ or pass opts.fetchImpl.");
92
+ }
93
+ this.fetchImpl = opts.fetchImpl ?? defaultFetch!;
94
+ }
95
+
96
+ /**
97
+ * Idempotent. Failures clear the cached promise so a stale
98
+ * daemon that comes back online isn't permanently rejected.
99
+ */
100
+ ensureStarted(): Promise<void> {
101
+ this.disposed = false;
102
+ if (this.startPromise) return this.startPromise;
103
+ this.startPromise = this.doStart().catch(err => {
104
+ this.startPromise = null;
105
+ throw err;
106
+ });
107
+ return this.startPromise;
108
+ }
109
+
110
+ private async doStart(): Promise<void> {
111
+ await this.postStart();
112
+ await this.openStream();
113
+ }
114
+
115
+ private async postStart(): Promise<void> {
116
+ const url = joinUrl(this.opts.serverUrl, "/logs/start");
117
+ const body: Record<string, unknown> = {};
118
+ if (this.opts.device !== undefined) body.device = this.opts.device;
119
+ if (this.opts.port !== undefined) body.port = this.opts.port;
120
+ if (this.opts.bufferSize !== undefined) body.bufferSize = this.opts.bufferSize;
121
+ const payload = JSON.stringify(body);
122
+
123
+ // 429 retry budget: most parallel-shard 429 storms drain inside
124
+ // a handful of seconds (the `express-rate-limit` window is
125
+ // 15 minutes by default but the server should be started with
126
+ // `--no-rate-limit` for sharded runs — see
127
+ // `scripts/run-shards-on-devices.sh::rk-server section`). The
128
+ // retry here is defence-in-depth for the case where a user
129
+ // points `device-test-client` at a hand-started server they
130
+ // didn't realise had rate limits on. Cap attempts so a
131
+ // mis-configured server fails loudly instead of hanging.
132
+ const MAX_429_ATTEMPTS = 3;
133
+ let attempt = 0;
134
+ let res: Response;
135
+ while (true) {
136
+ try {
137
+ res = await this.fetchImpl(url, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: payload
141
+ });
142
+ } catch (err) {
143
+ throw new LogSessionStartError(
144
+ `POST ${url} failed: ${(err as Error).message}. Is \`rk server\` running?`,
145
+ {
146
+ serverUrl: this.opts.serverUrl,
147
+ detail: (err as Error).message
148
+ }
149
+ );
150
+ }
151
+
152
+ if (res.status !== 429 || attempt >= MAX_429_ATTEMPTS - 1) break;
153
+
154
+ // Honour `Retry-After` when the server provides it
155
+ // (`express-rate-limit` sets it via `standardHeaders: true`).
156
+ // Fall back to exponential backoff capped at 8s so a hung
157
+ // window can't strand the test for the full 15-min default.
158
+ const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
159
+ const backoffMs = retryAfterMs ?? Math.min(8_000, 500 * 2 ** attempt);
160
+ attempt += 1;
161
+ await delay(backoffMs);
162
+ }
163
+
164
+ if (res.ok) return;
165
+
166
+ // 400 "already active" → another worker already started a
167
+ // session for the same (device, port). Attach via SSE.
168
+ if (res.status === 400) {
169
+ const detail = await safeReadText(res);
170
+ if (/already active/i.test(detail)) return;
171
+ throw new LogSessionStartError(`POST ${url} returned 400: ${detail || "<no body>"}`, {
172
+ serverUrl: this.opts.serverUrl,
173
+ statusCode: 400,
174
+ detail
175
+ });
176
+ }
177
+
178
+ const detail = await safeReadText(res);
179
+ // Augment the 429 message with operator guidance — the bare
180
+ // `RATE_LIMIT_EXCEEDED` body is unhelpful on the failing test
181
+ // line. The same hint lives in `scripts/run-shards-on-devices.sh`.
182
+ const hint =
183
+ res.status === 429
184
+ ? ` Restart with \`rk server stop && rk server start --no-rate-limit --daemon\` ` +
185
+ `or set \`RK_DISABLE_RATE_LIMIT=1\` before \`rk server start\` for parallel test runs.`
186
+ : "";
187
+ throw new LogSessionStartError(`POST ${url} returned HTTP ${res.status}${detail ? `: ${detail}` : ""}${hint}`, {
188
+ serverUrl: this.opts.serverUrl,
189
+ statusCode: res.status,
190
+ detail
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Open SSE and await readiness. First `onStatus`/`onLine` →
196
+ * resolve; pre-ready `onError` → reject (so `ensureStarted()`
197
+ * surfaces the failure rather than handing back a dead stream);
198
+ * `onClose` triggers re-attach via `handleRemoteClose`.
199
+ */
200
+ private async openStream(): Promise<void> {
201
+ if (this.stream) return;
202
+ let resolveReady!: () => void;
203
+ let rejectReady!: (err: Error) => void;
204
+ const ready = new Promise<void>((resolve, reject) => {
205
+ resolveReady = resolve;
206
+ rejectReady = reject;
207
+ });
208
+ let readyResolved = false;
209
+ const markReady = (): void => {
210
+ if (readyResolved) return;
211
+ readyResolved = true;
212
+ resolveReady();
213
+ };
214
+
215
+ const stream = this.streamFactory(
216
+ {
217
+ serverUrl: this.opts.serverUrl,
218
+ device: this.opts.device,
219
+ port: this.opts.port
220
+ },
221
+ {
222
+ onLine: (line, serverSeq) => {
223
+ // Replay-on-attach counts as live — resolve
224
+ // readiness before appending.
225
+ markReady();
226
+ // Drop replay duplicates so gap-tracking and
227
+ // `entriesSince` accounting stay correct.
228
+ if (serverSeq !== undefined) {
229
+ if (serverSeq <= this.lastServerSeq) return;
230
+ this.lastServerSeq = serverSeq;
231
+ }
232
+ this.buffer.append(line);
233
+ },
234
+ onStatus: status => {
235
+ markReady();
236
+ if (status === "reconnecting") {
237
+ this.buffer.markGapStart();
238
+ } else if (status === "active") {
239
+ // Initial-attach `active` is spurious; only
240
+ // close a gap when we were reconnecting.
241
+ if (this.lastStatus === "reconnecting") {
242
+ this.buffer.markGapEnd();
243
+ }
244
+ } else if (status === "stopped") {
245
+ // Server-side stop. In-flight `waitForLog` /
246
+ // `expectNoLog` callers complete against the
247
+ // captured buffer or surface a timeout.
248
+ this.stream?.close();
249
+ this.stream = null;
250
+ }
251
+ this.lastStatus = status;
252
+ },
253
+ onError: msg => {
254
+ if (!readyResolved) {
255
+ readyResolved = true;
256
+ rejectReady(
257
+ new LogSessionStartError(`LogSession: SSE attach failed before ready: ${msg}`, {
258
+ serverUrl: this.opts.serverUrl,
259
+ detail: msg
260
+ })
261
+ );
262
+ return;
263
+ }
264
+ // Post-ready errors are informational; warn once
265
+ // per session to keep CI logs readable.
266
+ if (!this.postReadyErrorWarned) {
267
+ this.postReadyErrorWarned = true;
268
+
269
+ console.warn(`[device-test-client] LogSession SSE error (post-ready): ${msg}`);
270
+ }
271
+ },
272
+ onClose: () => this.handleRemoteClose()
273
+ }
274
+ );
275
+ this.stream = stream;
276
+ stream.open();
277
+
278
+ try {
279
+ await ready;
280
+ } catch (err) {
281
+ // Tear the half-opened stream down for a clean retry.
282
+ try {
283
+ stream.close();
284
+ } catch {
285
+ // best-effort
286
+ }
287
+ this.stream = null;
288
+ throw err;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Handle server-initiated stream termination. Clears memoized
294
+ * session state so the next `ensureLogSession()` re-runs
295
+ * `/logs/start` + reopens SSE. Conservatively closes any open
296
+ * gap at the high-water seq (we can't distinguish "no lines
297
+ * lost" from "lines lost we never heard about").
298
+ */
299
+ private handleRemoteClose(): void {
300
+ this.buffer.markGapEnd();
301
+ this.stream = null;
302
+ this.startPromise = null;
303
+ this.lastStatus = null;
304
+ this.postReadyErrorWarned = false;
305
+ }
306
+
307
+ /**
308
+ * Drop the current subscription and open a fresh one.
309
+ *
310
+ * `/logs/tail` replays the whole server-side buffer on attach and
311
+ * `lastServerSeq` drops the duplicates, so a line the previous
312
+ * subscription never delivered still reaches the buffer — and
313
+ * through it any in-flight waiter, since `append` notifies
314
+ * subscribers. A subscription that stops delivering while the
315
+ * server still considers it active reports nothing and closes
316
+ * nothing, so re-attaching is the only way to find out whether
317
+ * the lines exist.
318
+ *
319
+ * Records no gap: a replay fills the window rather than losing
320
+ * it, and an open gap would mark the replayed lines themselves
321
+ * suspect, which is the opposite of the intent. Loss on the
322
+ * device→server leg is the server's own `reconnecting` status,
323
+ * which already marks a gap through `onStatus`.
324
+ *
325
+ * Concurrent callers share one re-attach. After `dispose()` it opens
326
+ * nothing: a waiter whose deadline fires once its test was torn down
327
+ * must not reopen the subscription the teardown closed.
328
+ */
329
+ resync(): Promise<void> {
330
+ if (this.resyncPromise) return this.resyncPromise;
331
+ if (this.disposed) return Promise.resolve();
332
+ const attempt = (async () => {
333
+ const generation = this.disposeCount;
334
+ this.teardownStream();
335
+ await this.ensureStarted();
336
+ // A `dispose()` that landed while we were attaching wins:
337
+ // the caller asked for no subscription, not a newer one.
338
+ // Without this a worker teardown can be followed by a
339
+ // fresh SSE socket opening behind it.
340
+ if (this.disposeCount !== generation) this.teardownStream();
341
+ })();
342
+ this.resyncPromise = attempt.finally(() => {
343
+ this.resyncPromise = null;
344
+ });
345
+ return this.resyncPromise;
346
+ }
347
+
348
+ /**
349
+ * Close the SSE subscription. Does NOT `/logs/stop` — global
350
+ * teardown owns server-side lifecycle so cross-worker sessions
351
+ * aren't torn down mid-suite.
352
+ */
353
+ dispose(): void {
354
+ this.disposed = true;
355
+ this.disposeCount += 1;
356
+ this.teardownStream();
357
+ }
358
+
359
+ /**
360
+ * Close the stream and clear the memoized session state so the
361
+ * next `ensureStarted()` re-runs `/logs/start` and reopens SSE.
362
+ * The buffer is deliberately untouched: its entries and gap
363
+ * records outlive any one subscription.
364
+ */
365
+ private teardownStream(): void {
366
+ if (this.stream) {
367
+ try {
368
+ this.stream.close();
369
+ } catch {
370
+ // best-effort; close errors are not actionable here
371
+ }
372
+ this.stream = null;
373
+ }
374
+ this.startPromise = null;
375
+ this.lastStatus = null;
376
+ this.postReadyErrorWarned = false;
377
+ }
378
+ }
379
+
380
+ /** URL join that doesn't mangle paths when base has no trailing slash. */
381
+ function joinUrl(base: string, path: string): string {
382
+ const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
383
+ return trimmed + path;
384
+ }
385
+
386
+ async function safeReadText(res: Response): Promise<string> {
387
+ try {
388
+ return await res.text();
389
+ } catch {
390
+ return "";
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Parse an HTTP `Retry-After` header into milliseconds. The header
396
+ * RFC permits two forms:
397
+ * - delta-seconds (a non-negative integer)
398
+ * - HTTP-date (RFC 7231 §7.1.1.1)
399
+ * Returns `undefined` for anything malformed so the caller can fall
400
+ * back to its own backoff schedule rather than waiting forever on a
401
+ * NaN value. Clamps absurd values (>5 minutes) so a misconfigured
402
+ * server can't strand the test process.
403
+ */
404
+ function parseRetryAfterMs(headerValue: string | null): number | undefined {
405
+ if (!headerValue) return undefined;
406
+ const trimmed = headerValue.trim();
407
+ if (trimmed === "") return undefined;
408
+ const asNumber = Number(trimmed);
409
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
410
+ return clampRetryAfter(asNumber * 1000);
411
+ }
412
+ const asDate = Date.parse(trimmed);
413
+ if (Number.isFinite(asDate)) {
414
+ const delta = asDate - Date.now();
415
+ return delta > 0 ? clampRetryAfter(delta) : 0;
416
+ }
417
+ return undefined;
418
+ }
419
+
420
+ const RETRY_AFTER_CAP_MS = 5 * 60 * 1000;
421
+
422
+ function clampRetryAfter(ms: number): number {
423
+ if (ms <= 0) return 0;
424
+ if (ms > RETRY_AFTER_CAP_MS) return RETRY_AFTER_CAP_MS;
425
+ return Math.floor(ms);
426
+ }
427
+
428
+ function delay(ms: number): Promise<void> {
429
+ return new Promise(resolve => {
430
+ const timer = setTimeout(resolve, ms);
431
+ // `.unref()` is a Node-only Timer method; other hosts or test
432
+ // doubles may omit it. Optional-chain so the
433
+ // package stays universal.
434
+ (timer as { unref?: () => void }).unref?.();
435
+ });
436
+ }