@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.
- 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 +525 -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 +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -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 +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -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 +773 -0
- package/src/index.ts +186 -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 +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -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 +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +443 -0
|
@@ -0,0 +1,265 @@
|
|
|
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: `from == to` is empty. Includes the currently-open
|
|
206
|
+
* gap so a still-reconnecting stream is detectable mid-wait.
|
|
207
|
+
*/
|
|
208
|
+
hasGapWithin(from: LogPosition, to?: LogPosition): Gap | null {
|
|
209
|
+
const fromN = parsePos(from);
|
|
210
|
+
const toN = to !== undefined ? parsePos(to) : this.nextSeq;
|
|
211
|
+
if (toN <= fromN) return null;
|
|
212
|
+
for (const g of this.gaps) {
|
|
213
|
+
if (g.start < toN && g.end > fromN) return { ...g };
|
|
214
|
+
}
|
|
215
|
+
if (this.openGapStart !== null) {
|
|
216
|
+
// Open gap extends forward indefinitely; any un-arrived
|
|
217
|
+
// line is "maybe-lost". `start === end` is valid and
|
|
218
|
+
// means "reconnecting now, no lines missing yet".
|
|
219
|
+
if (this.openGapStart <= toN) {
|
|
220
|
+
return { start: this.openGapStart, end: this.nextSeq };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Gap containing `seq`, half-open. Strictly narrower than
|
|
228
|
+
* `hasGapWithin`: gaps fully before `seq` don't invalidate a
|
|
229
|
+
* clean match because `since` is captured pre-action.
|
|
230
|
+
*/
|
|
231
|
+
findGapContaining(seq: number): Gap | null {
|
|
232
|
+
for (const g of this.gaps) {
|
|
233
|
+
if (seq >= g.start && seq < g.end) return { ...g };
|
|
234
|
+
}
|
|
235
|
+
if (this.openGapStart !== null && seq >= this.openGapStart) {
|
|
236
|
+
return { start: this.openGapStart, end: this.nextSeq };
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Subscribe to new appends. Returns an unsubscribe thunk. */
|
|
242
|
+
subscribe(cb: (e: LogEntry) => void): () => void {
|
|
243
|
+
this.listeners.add(cb);
|
|
244
|
+
return () => {
|
|
245
|
+
this.listeners.delete(cb);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Drop the oldest `dropHalf` entries without recording a gap. */
|
|
250
|
+
private evict(): void {
|
|
251
|
+
const toDrop = Math.min(this.dropHalf, this.entries.length);
|
|
252
|
+
this.entries.splice(0, toDrop);
|
|
253
|
+
this.minAvailableSeq = this.entries[0]?.seq ?? this.nextSeq;
|
|
254
|
+
// Prune gaps that lie entirely below the new minimum. Without
|
|
255
|
+
// this, long suites with frequent reconnects accumulate stale
|
|
256
|
+
// gap records indefinitely — `hasGapWithin`/`findGapContaining`
|
|
257
|
+
// cannot reach them via valid (non-throwing) queries because
|
|
258
|
+
// the seqs they cover are below `minAvailableSeq`. `gaps` is
|
|
259
|
+
// append-only in seq order, so a leading-prefix splice is safe.
|
|
260
|
+
const min = this.minAvailableSeq;
|
|
261
|
+
let i = 0;
|
|
262
|
+
while (i < this.gaps.length && this.gaps[i]!.end <= min) i++;
|
|
263
|
+
if (i > 0) this.gaps.splice(0, i);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
|
|
76
|
+
constructor(opts: LogSessionOptions) {
|
|
77
|
+
this.opts = opts;
|
|
78
|
+
this.buffer = opts.buffer ?? new LogBuffer();
|
|
79
|
+
this.streamFactory = opts.streamFactory ?? ((streamOpts, cbs) => new LogStreamClient(streamOpts, cbs));
|
|
80
|
+
// Friendly diagnostic instead of "fetch is not a function".
|
|
81
|
+
const defaultFetch = (globalThis as { fetch?: typeof fetch }).fetch;
|
|
82
|
+
if (!opts.fetchImpl && !defaultFetch) {
|
|
83
|
+
throw new Error("LogSession requires a fetch implementation. Upgrade to Node 18+ or pass opts.fetchImpl.");
|
|
84
|
+
}
|
|
85
|
+
this.fetchImpl = opts.fetchImpl ?? defaultFetch!;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Idempotent. Failures clear the cached promise so a stale
|
|
90
|
+
* daemon that comes back online isn't permanently rejected.
|
|
91
|
+
*/
|
|
92
|
+
ensureStarted(): Promise<void> {
|
|
93
|
+
if (this.startPromise) return this.startPromise;
|
|
94
|
+
this.startPromise = this.doStart().catch(err => {
|
|
95
|
+
this.startPromise = null;
|
|
96
|
+
throw err;
|
|
97
|
+
});
|
|
98
|
+
return this.startPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async doStart(): Promise<void> {
|
|
102
|
+
await this.postStart();
|
|
103
|
+
await this.openStream();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async postStart(): Promise<void> {
|
|
107
|
+
const url = joinUrl(this.opts.serverUrl, "/logs/start");
|
|
108
|
+
const body: Record<string, unknown> = {};
|
|
109
|
+
if (this.opts.device !== undefined) body.device = this.opts.device;
|
|
110
|
+
if (this.opts.port !== undefined) body.port = this.opts.port;
|
|
111
|
+
if (this.opts.bufferSize !== undefined) body.bufferSize = this.opts.bufferSize;
|
|
112
|
+
const payload = JSON.stringify(body);
|
|
113
|
+
|
|
114
|
+
// 429 retry budget: most parallel-shard 429 storms drain inside
|
|
115
|
+
// a handful of seconds (the `express-rate-limit` window is
|
|
116
|
+
// 15 minutes by default but the server should be started with
|
|
117
|
+
// `--no-rate-limit` for sharded runs — see
|
|
118
|
+
// `scripts/run-shards-on-devices.sh::rk-server section`). The
|
|
119
|
+
// retry here is defence-in-depth for the case where a user
|
|
120
|
+
// points `device-test-client` at a hand-started server they
|
|
121
|
+
// didn't realise had rate limits on. Cap attempts so a
|
|
122
|
+
// mis-configured server fails loudly instead of hanging.
|
|
123
|
+
const MAX_429_ATTEMPTS = 3;
|
|
124
|
+
let attempt = 0;
|
|
125
|
+
let res: Response;
|
|
126
|
+
while (true) {
|
|
127
|
+
try {
|
|
128
|
+
res = await this.fetchImpl(url, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: payload
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new LogSessionStartError(
|
|
135
|
+
`POST ${url} failed: ${(err as Error).message}. Is \`rk server\` running?`,
|
|
136
|
+
{
|
|
137
|
+
serverUrl: this.opts.serverUrl,
|
|
138
|
+
detail: (err as Error).message
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (res.status !== 429 || attempt >= MAX_429_ATTEMPTS - 1) break;
|
|
144
|
+
|
|
145
|
+
// Honour `Retry-After` when the server provides it
|
|
146
|
+
// (`express-rate-limit` sets it via `standardHeaders: true`).
|
|
147
|
+
// Fall back to exponential backoff capped at 8s so a hung
|
|
148
|
+
// window can't strand the test for the full 15-min default.
|
|
149
|
+
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
|
|
150
|
+
const backoffMs = retryAfterMs ?? Math.min(8_000, 500 * 2 ** attempt);
|
|
151
|
+
attempt += 1;
|
|
152
|
+
await delay(backoffMs);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (res.ok) return;
|
|
156
|
+
|
|
157
|
+
// 400 "already active" → another worker already started a
|
|
158
|
+
// session for the same (device, port). Attach via SSE.
|
|
159
|
+
if (res.status === 400) {
|
|
160
|
+
const detail = await safeReadText(res);
|
|
161
|
+
if (/already active/i.test(detail)) return;
|
|
162
|
+
throw new LogSessionStartError(`POST ${url} returned 400: ${detail || "<no body>"}`, {
|
|
163
|
+
serverUrl: this.opts.serverUrl,
|
|
164
|
+
statusCode: 400,
|
|
165
|
+
detail
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const detail = await safeReadText(res);
|
|
170
|
+
// Augment the 429 message with operator guidance — the bare
|
|
171
|
+
// `RATE_LIMIT_EXCEEDED` body is unhelpful on the failing test
|
|
172
|
+
// line. The same hint lives in `scripts/run-shards-on-devices.sh`.
|
|
173
|
+
const hint =
|
|
174
|
+
res.status === 429
|
|
175
|
+
? ` Restart with \`rk server stop && rk server start --no-rate-limit --daemon\` ` +
|
|
176
|
+
`or set \`RK_DISABLE_RATE_LIMIT=1\` before \`rk server start\` for parallel test runs.`
|
|
177
|
+
: "";
|
|
178
|
+
throw new LogSessionStartError(`POST ${url} returned HTTP ${res.status}${detail ? `: ${detail}` : ""}${hint}`, {
|
|
179
|
+
serverUrl: this.opts.serverUrl,
|
|
180
|
+
statusCode: res.status,
|
|
181
|
+
detail
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Open SSE and await readiness. First `onStatus`/`onLine` →
|
|
187
|
+
* resolve; pre-ready `onError` → reject (so `ensureStarted()`
|
|
188
|
+
* surfaces the failure rather than handing back a dead stream);
|
|
189
|
+
* `onClose` triggers re-attach via `handleRemoteClose`.
|
|
190
|
+
*/
|
|
191
|
+
private async openStream(): Promise<void> {
|
|
192
|
+
if (this.stream) return;
|
|
193
|
+
let resolveReady!: () => void;
|
|
194
|
+
let rejectReady!: (err: Error) => void;
|
|
195
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
196
|
+
resolveReady = resolve;
|
|
197
|
+
rejectReady = reject;
|
|
198
|
+
});
|
|
199
|
+
let readyResolved = false;
|
|
200
|
+
const markReady = (): void => {
|
|
201
|
+
if (readyResolved) return;
|
|
202
|
+
readyResolved = true;
|
|
203
|
+
resolveReady();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const stream = this.streamFactory(
|
|
207
|
+
{
|
|
208
|
+
serverUrl: this.opts.serverUrl,
|
|
209
|
+
device: this.opts.device,
|
|
210
|
+
port: this.opts.port
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
onLine: (line, serverSeq) => {
|
|
214
|
+
// Replay-on-attach counts as live — resolve
|
|
215
|
+
// readiness before appending.
|
|
216
|
+
markReady();
|
|
217
|
+
// Drop replay duplicates so gap-tracking and
|
|
218
|
+
// `entriesSince` accounting stay correct.
|
|
219
|
+
if (serverSeq !== undefined) {
|
|
220
|
+
if (serverSeq <= this.lastServerSeq) return;
|
|
221
|
+
this.lastServerSeq = serverSeq;
|
|
222
|
+
}
|
|
223
|
+
this.buffer.append(line);
|
|
224
|
+
},
|
|
225
|
+
onStatus: status => {
|
|
226
|
+
markReady();
|
|
227
|
+
if (status === "reconnecting") {
|
|
228
|
+
this.buffer.markGapStart();
|
|
229
|
+
} else if (status === "active") {
|
|
230
|
+
// Initial-attach `active` is spurious; only
|
|
231
|
+
// close a gap when we were reconnecting.
|
|
232
|
+
if (this.lastStatus === "reconnecting") {
|
|
233
|
+
this.buffer.markGapEnd();
|
|
234
|
+
}
|
|
235
|
+
} else if (status === "stopped") {
|
|
236
|
+
// Server-side stop. In-flight `waitForLog` /
|
|
237
|
+
// `expectNoLog` callers complete against the
|
|
238
|
+
// captured buffer or surface a timeout.
|
|
239
|
+
this.stream?.close();
|
|
240
|
+
this.stream = null;
|
|
241
|
+
}
|
|
242
|
+
this.lastStatus = status;
|
|
243
|
+
},
|
|
244
|
+
onError: msg => {
|
|
245
|
+
if (!readyResolved) {
|
|
246
|
+
readyResolved = true;
|
|
247
|
+
rejectReady(
|
|
248
|
+
new LogSessionStartError(`LogSession: SSE attach failed before ready: ${msg}`, {
|
|
249
|
+
serverUrl: this.opts.serverUrl,
|
|
250
|
+
detail: msg
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Post-ready errors are informational; warn once
|
|
256
|
+
// per session to keep CI logs readable.
|
|
257
|
+
if (!this.postReadyErrorWarned) {
|
|
258
|
+
this.postReadyErrorWarned = true;
|
|
259
|
+
|
|
260
|
+
console.warn(`[device-test-client] LogSession SSE error (post-ready): ${msg}`);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
onClose: () => this.handleRemoteClose()
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
this.stream = stream;
|
|
267
|
+
stream.open();
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await ready;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// Tear the half-opened stream down for a clean retry.
|
|
273
|
+
try {
|
|
274
|
+
stream.close();
|
|
275
|
+
} catch {
|
|
276
|
+
// best-effort
|
|
277
|
+
}
|
|
278
|
+
this.stream = null;
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Handle server-initiated stream termination. Clears memoized
|
|
285
|
+
* session state so the next `ensureLogSession()` re-runs
|
|
286
|
+
* `/logs/start` + reopens SSE. Conservatively closes any open
|
|
287
|
+
* gap at the high-water seq (we can't distinguish "no lines
|
|
288
|
+
* lost" from "lines lost we never heard about").
|
|
289
|
+
*/
|
|
290
|
+
private handleRemoteClose(): void {
|
|
291
|
+
this.buffer.markGapEnd();
|
|
292
|
+
this.stream = null;
|
|
293
|
+
this.startPromise = null;
|
|
294
|
+
this.lastStatus = null;
|
|
295
|
+
this.postReadyErrorWarned = false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Close the SSE subscription. Does NOT `/logs/stop` — global
|
|
300
|
+
* teardown owns server-side lifecycle so cross-worker sessions
|
|
301
|
+
* aren't torn down mid-suite.
|
|
302
|
+
*/
|
|
303
|
+
dispose(): void {
|
|
304
|
+
if (this.stream) {
|
|
305
|
+
try {
|
|
306
|
+
this.stream.close();
|
|
307
|
+
} catch {
|
|
308
|
+
// best-effort; close errors are not actionable here
|
|
309
|
+
}
|
|
310
|
+
this.stream = null;
|
|
311
|
+
}
|
|
312
|
+
this.startPromise = null;
|
|
313
|
+
this.lastStatus = null;
|
|
314
|
+
this.postReadyErrorWarned = false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** URL join that doesn't mangle paths when base has no trailing slash. */
|
|
319
|
+
function joinUrl(base: string, path: string): string {
|
|
320
|
+
const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
321
|
+
return trimmed + path;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function safeReadText(res: Response): Promise<string> {
|
|
325
|
+
try {
|
|
326
|
+
return await res.text();
|
|
327
|
+
} catch {
|
|
328
|
+
return "";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Parse an HTTP `Retry-After` header into milliseconds. The header
|
|
334
|
+
* RFC permits two forms:
|
|
335
|
+
* - delta-seconds (a non-negative integer)
|
|
336
|
+
* - HTTP-date (RFC 7231 §7.1.1.1)
|
|
337
|
+
* Returns `undefined` for anything malformed so the caller can fall
|
|
338
|
+
* back to its own backoff schedule rather than waiting forever on a
|
|
339
|
+
* NaN value. Clamps absurd values (>5 minutes) so a misconfigured
|
|
340
|
+
* server can't strand the test process.
|
|
341
|
+
*/
|
|
342
|
+
function parseRetryAfterMs(headerValue: string | null): number | undefined {
|
|
343
|
+
if (!headerValue) return undefined;
|
|
344
|
+
const trimmed = headerValue.trim();
|
|
345
|
+
if (trimmed === "") return undefined;
|
|
346
|
+
const asNumber = Number(trimmed);
|
|
347
|
+
if (Number.isFinite(asNumber) && asNumber >= 0) {
|
|
348
|
+
return clampRetryAfter(asNumber * 1000);
|
|
349
|
+
}
|
|
350
|
+
const asDate = Date.parse(trimmed);
|
|
351
|
+
if (Number.isFinite(asDate)) {
|
|
352
|
+
const delta = asDate - Date.now();
|
|
353
|
+
return delta > 0 ? clampRetryAfter(delta) : 0;
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const RETRY_AFTER_CAP_MS = 5 * 60 * 1000;
|
|
359
|
+
|
|
360
|
+
function clampRetryAfter(ms: number): number {
|
|
361
|
+
if (ms <= 0) return 0;
|
|
362
|
+
if (ms > RETRY_AFTER_CAP_MS) return RETRY_AFTER_CAP_MS;
|
|
363
|
+
return Math.floor(ms);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function delay(ms: number): Promise<void> {
|
|
367
|
+
return new Promise(resolve => {
|
|
368
|
+
const timer = setTimeout(resolve, ms);
|
|
369
|
+
// `.unref()` is a Node-only Timer method; other hosts or test
|
|
370
|
+
// doubles may omit it. Optional-chain so the
|
|
371
|
+
// package stays universal.
|
|
372
|
+
(timer as { unref?: () => void }).unref?.();
|
|
373
|
+
});
|
|
374
|
+
}
|