@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,278 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Append-only in-memory log buffer with seq-numbered entries, gap
5
+ * tracking, and bounded memory. Pure (no I/O) so unit tests can
6
+ * drive it directly.
7
+ *
8
+ * Memory cap: when `max` is hit we drop the oldest `dropHalf` entries
9
+ * and advance `minAvailableSeq`. Client-side eviction is NOT recorded
10
+ * as a gap (no device-side loss occurred). `linesSince(pos)` with
11
+ * `pos < minAvailableSeq` throws `LogBufferOverflowError` so tests
12
+ * fail loudly instead of silently dropping lines.
13
+ */
14
+
15
+ import { LOG_BUFFER_DROP_HALF, LOG_BUFFER_MAX_LINES } from "../constants";
16
+ import { LogBufferOverflowError } from "../errors";
17
+ import type { LogLine, LogPosition } from "../types";
18
+
19
+ export interface LogEntry {
20
+ /** Monotonically-increasing sequence number, starting at 0. */
21
+ seq: number;
22
+ /** Raw log line (no trailing newline guaranteed). */
23
+ line: LogLine;
24
+ /** Host-side wall-clock at the time of append (ms since epoch). */
25
+ ts: number;
26
+ }
27
+
28
+ /** Half-open range `[start, end)` of lost lines. */
29
+ export interface Gap {
30
+ start: number;
31
+ end: number;
32
+ }
33
+
34
+ export interface LogBufferOptions {
35
+ /** Hard cap on retained entries. Defaults to `LOG_BUFFER_MAX_LINES`. */
36
+ max?: number;
37
+ /** Count of oldest entries to drop when cap is hit. Defaults to `LOG_BUFFER_DROP_HALF`. */
38
+ dropHalf?: number;
39
+ }
40
+
41
+ /**
42
+ * Lifetime stream-health stats. Counters are monotonic; consumers
43
+ * wanting deltas snapshot before/after the window of interest.
44
+ */
45
+ export interface LogBufferStats {
46
+ /** Number of closed gaps recorded since construction. */
47
+ gapCount: number;
48
+ /** Sum of `(end - start)` across every closed gap. */
49
+ totalGapSeqSpan: number;
50
+ /** Wall-clock ms (`Date.now()`) of the most recent `markGapEnd()`, or `null` if none. */
51
+ lastGapAt: number | null;
52
+ /** Whether a gap is currently open (`reconnecting` not yet resolved by `active`). */
53
+ openGap: boolean;
54
+ }
55
+
56
+ /**
57
+ * Parse an opaque position token back to its seq number.
58
+ * Package-internal: deep import allowed but NOT under SemVer.
59
+ * Exists so in-package callers share a single strict validator.
60
+ */
61
+ export function parsePos(pos: LogPosition): number {
62
+ const n = Number.parseInt(pos, 10);
63
+ if (!Number.isFinite(n) || n < 0 || String(n) !== pos) {
64
+ throw new Error(
65
+ `Invalid LogPosition "${pos}": expected a non-negative decimal string produced by logPosition().`
66
+ );
67
+ }
68
+ return n;
69
+ }
70
+
71
+ export class LogBuffer {
72
+ private readonly entries: LogEntry[] = [];
73
+ private nextSeq = 0;
74
+ // Smallest seq still retained; grows on eviction so
75
+ // `linesSince(pos < minAvailableSeq)` can fail fast.
76
+ private minAvailableSeq = 0;
77
+ private readonly gaps: Gap[] = [];
78
+ private openGapStart: number | null = null;
79
+ private readonly listeners = new Set<(e: LogEntry) => void>();
80
+
81
+ private readonly max: number;
82
+ private readonly dropHalf: number;
83
+
84
+ // Lifetime counters; not reset on eviction (observability
85
+ // surface, not buffer content).
86
+ private gapCount = 0;
87
+ private totalGapSeqSpan = 0;
88
+ private lastGapAt: number | null = null;
89
+
90
+ constructor(opts: LogBufferOptions = {}) {
91
+ this.max = Math.max(2, opts.max ?? LOG_BUFFER_MAX_LINES);
92
+ // dropHalf < max so eviction makes progress; floor at 1.
93
+ const requestedDrop = opts.dropHalf ?? LOG_BUFFER_DROP_HALF;
94
+ this.dropHalf = Math.min(this.max - 1, Math.max(1, requestedDrop));
95
+ }
96
+
97
+ /** Append a new line, assigning the next seq. Returns the created entry. */
98
+ append(line: LogLine): LogEntry {
99
+ const entry: LogEntry = { seq: this.nextSeq, line, ts: Date.now() };
100
+ this.nextSeq += 1;
101
+ this.entries.push(entry);
102
+ if (this.entries.length > this.max) {
103
+ this.evict();
104
+ }
105
+ // Fire after eviction so listeners observing
106
+ // `currentPosition()` see consistent state.
107
+ for (const cb of Array.from(this.listeners)) {
108
+ try {
109
+ cb(entry);
110
+ } catch {
111
+ // Listener errors must not leak into the append path.
112
+ }
113
+ }
114
+ return entry;
115
+ }
116
+
117
+ /**
118
+ * Open a disconnect window. No-op if already open.
119
+ * `markGapEnd()` will close it as `[start, end)`.
120
+ */
121
+ markGapStart(): void {
122
+ if (this.openGapStart !== null) return;
123
+ this.openGapStart = this.nextSeq;
124
+ }
125
+
126
+ /**
127
+ * Close the open gap at the current high-water seq. No-op if no
128
+ * gap is open. A zero-width close still records a
129
+ * `[start, start+1)` gap: a reconnect cycle taints the
130
+ * transition point even if no lines were demonstrably lost.
131
+ */
132
+ markGapEnd(): void {
133
+ if (this.openGapStart === null) return;
134
+ const start = this.openGapStart;
135
+ this.openGapStart = null;
136
+ const end = Math.max(this.nextSeq, start + 1);
137
+ this.gaps.push({ start, end });
138
+ this.gapCount += 1;
139
+ this.totalGapSeqSpan += end - start;
140
+ this.lastGapAt = Date.now();
141
+ }
142
+
143
+ /** Snapshot of lifetime gap stats. */
144
+ getStats(): LogBufferStats {
145
+ return {
146
+ gapCount: this.gapCount,
147
+ totalGapSeqSpan: this.totalGapSeqSpan,
148
+ lastGapAt: this.lastGapAt,
149
+ openGap: this.openGapStart !== null
150
+ };
151
+ }
152
+
153
+ /** Current high-water position as an opaque decimal string. */
154
+ currentPosition(): LogPosition {
155
+ return String(this.nextSeq);
156
+ }
157
+
158
+ /**
159
+ * Return lines with `seq >= parseInt(pos)`. Throws
160
+ * `LogBufferOverflowError` when `pos` references evicted lines.
161
+ */
162
+ linesSince(pos: LogPosition): LogLine[] {
163
+ const from = parsePos(pos);
164
+ if (from < this.minAvailableSeq) {
165
+ throw new LogBufferOverflowError(
166
+ `Requested log position "${pos}" is below the buffer's minimum available position "${String(this.minAvailableSeq)}". ` +
167
+ `The buffer dropped older entries to stay under its memory cap — raise the cap, shorten the wait window, ` +
168
+ `or take a fresh logPosition() after each assertion.`,
169
+ {
170
+ requested: pos,
171
+ minAvailable: String(this.minAvailableSeq)
172
+ }
173
+ );
174
+ }
175
+ if (from >= this.nextSeq) return [];
176
+ const out: LogLine[] = [];
177
+ for (const e of this.entries) {
178
+ if (e.seq >= from) out.push(e.line);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Same as `linesSince` but returns full entries; needed by
185
+ * wait/expect paths for gap-overlap checks.
186
+ */
187
+ entriesSince(pos: LogPosition): LogEntry[] {
188
+ const from = parsePos(pos);
189
+ if (from < this.minAvailableSeq) {
190
+ throw new LogBufferOverflowError(
191
+ `Requested log position "${pos}" is below the buffer's minimum available position "${String(this.minAvailableSeq)}".`,
192
+ { requested: pos, minAvailable: String(this.minAvailableSeq) }
193
+ );
194
+ }
195
+ if (from >= this.nextSeq) return [];
196
+ const out: LogEntry[] = [];
197
+ for (const e of this.entries) {
198
+ if (e.seq >= from) out.push(e);
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * First gap overlapping `[from, to ?? currentPosition)`.
205
+ * Half-open, and includes the currently-open gap so a
206
+ * still-reconnecting stream is detectable mid-wait.
207
+ *
208
+ * `from == to` is an empty *line* range but not an empty
209
+ * question: a wait that received nothing while the stream was
210
+ * reconnecting is the case where "no lines" and "lines we never
211
+ * heard about" are indistinguishable, so an open gap still
212
+ * answers. Only an inverted range answers `null` outright.
213
+ */
214
+ hasGapWithin(from: LogPosition, to?: LogPosition): Gap | null {
215
+ const fromN = parsePos(from);
216
+ const toN = to !== undefined ? parsePos(to) : this.nextSeq;
217
+ if (toN < fromN) return null;
218
+ // Overlap is the wrong test for an empty range — nothing
219
+ // overlaps it — so ask instead whether a gap contains `fromN`
220
+ // itself, which is what `findGapContaining` means by tainted
221
+ // and what a gap closed at `[fromN, fromN + 1)` records. A gap
222
+ // lying entirely to either side stays excluded either way.
223
+ const empty = toN === fromN;
224
+ for (const g of this.gaps) {
225
+ const reachesWindow = empty ? fromN >= g.start && fromN < g.end : g.start < toN && g.end > fromN;
226
+ if (reachesWindow) return { ...g };
227
+ }
228
+ if (this.openGapStart !== null) {
229
+ // Open gap extends forward indefinitely; any un-arrived
230
+ // line is "maybe-lost". `start === end` is valid and
231
+ // means "reconnecting now, no lines missing yet".
232
+ if (this.openGapStart <= toN) {
233
+ return { start: this.openGapStart, end: this.nextSeq };
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Gap containing `seq`, half-open. Strictly narrower than
241
+ * `hasGapWithin`: gaps fully before `seq` don't invalidate a
242
+ * clean match because `since` is captured pre-action.
243
+ */
244
+ findGapContaining(seq: number): Gap | null {
245
+ for (const g of this.gaps) {
246
+ if (seq >= g.start && seq < g.end) return { ...g };
247
+ }
248
+ if (this.openGapStart !== null && seq >= this.openGapStart) {
249
+ return { start: this.openGapStart, end: this.nextSeq };
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /** Subscribe to new appends. Returns an unsubscribe thunk. */
255
+ subscribe(cb: (e: LogEntry) => void): () => void {
256
+ this.listeners.add(cb);
257
+ return () => {
258
+ this.listeners.delete(cb);
259
+ };
260
+ }
261
+
262
+ /** Drop the oldest `dropHalf` entries without recording a gap. */
263
+ private evict(): void {
264
+ const toDrop = Math.min(this.dropHalf, this.entries.length);
265
+ this.entries.splice(0, toDrop);
266
+ this.minAvailableSeq = this.entries[0]?.seq ?? this.nextSeq;
267
+ // Prune gaps that lie entirely below the new minimum. Without
268
+ // this, long suites with frequent reconnects accumulate stale
269
+ // gap records indefinitely — `hasGapWithin`/`findGapContaining`
270
+ // cannot reach them via valid (non-throwing) queries because
271
+ // the seqs they cover are below `minAvailableSeq`. `gaps` is
272
+ // append-only in seq order, so a leading-prefix splice is safe.
273
+ const min = this.minAvailableSeq;
274
+ let i = 0;
275
+ while (i < this.gaps.length && this.gaps[i]!.end <= min) i++;
276
+ if (i > 0) this.gaps.splice(0, i);
277
+ }
278
+ }