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