@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,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
|
+
}
|