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