@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,1656 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ import {
8
+ EcpClient,
9
+ ROKU_INSTALLER_PORT,
10
+ tcpProbe,
11
+ xmlToJson,
12
+ xmlToKeyValue
13
+ } from "@roku-sdk/tooling/src/device-test-helpers";
14
+ import { PNG } from "pngjs";
15
+
16
+ import { summarizeTree, tryFindNode } from "../assertions/assertions";
17
+ import { pngsEqualOutsideMask, validateMasks } from "../assertions/screenshot-diff";
18
+ import {
19
+ DEFAULT_KEY_DELAY,
20
+ DEFAULT_REQUEST_TIMEOUT_MS,
21
+ DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
22
+ DEVICE_ONLINE_POLL_INTERVAL_MS,
23
+ ECP_INTERVAL_MS,
24
+ ECP_RETRY_BACKOFF_MS,
25
+ ENV_VAR,
26
+ HOME_SCREEN_GRACE_FRACTION,
27
+ HOME_SCREEN_GRACE_MIN_MS,
28
+ INSTALLER_PORT_PROBE_DEFAULT_TIMEOUT_MS,
29
+ INSTALLER_PORT_PROBE_INTERVAL_MS,
30
+ INSTALLER_PORT_TCP_TIMEOUT_MS,
31
+ READINESS_CONFIRM_POLLS,
32
+ READINESS_POLL_INTERVAL_MS,
33
+ READINESS_TIMEOUT_MS,
34
+ ROKU_LOG_PORT,
35
+ SCREENSHOT_STABLE_DEFAULT_INTERVAL_MS,
36
+ SYSTEM_DIALOG_TAGS,
37
+ WAIT_FOR_LOG_DEFAULT_TIMEOUT_MS,
38
+ WAIT_FOR_LOG_RECENT_CHARS_MAX,
39
+ WAIT_FOR_LOG_RECENT_LINES_MAX,
40
+ WAIT_FOR_NODE_DEFAULT_INTERVAL_MS
41
+ } from "../constants";
42
+ import {
43
+ AppCrashedError,
44
+ BuildFailedError,
45
+ DeployFailedError,
46
+ DeviceOfflineError,
47
+ LogBufferOverflowError,
48
+ LogStreamGapError,
49
+ RkClientError,
50
+ ScreenshotNotStableError,
51
+ UnexpectedLogError,
52
+ WaitForLogTimeoutError,
53
+ WaitForNodeTimeoutError
54
+ } from "../errors";
55
+ import { parsePos } from "../logs/log-buffer";
56
+ import { LogSession } from "../logs/log-session";
57
+ import type {
58
+ ActiveAppInfo,
59
+ AppUiNode,
60
+ BuildAndDeployOptions,
61
+ BuildAndLaunchWithParamsOptions,
62
+ CaptureStableOptions,
63
+ DeviceInfo,
64
+ EcpCategory,
65
+ KeySequenceOptions,
66
+ LaunchOptions,
67
+ LogBufferLike,
68
+ LogLine,
69
+ LogPosition,
70
+ LogQueryOptions,
71
+ LogSessionLike,
72
+ LogStreamHealth,
73
+ MaskRegion,
74
+ NodePredicate,
75
+ RkClientOptions,
76
+ SystemDialogNode,
77
+ WaitForDeviceFullyReadyOptions,
78
+ WaitForDeviceOnlineOptions,
79
+ WaitOptions
80
+ } from "../types";
81
+ import { getOrCreateBuildPromise, invalidateBuildCacheForApp } from "./build-cache";
82
+ import type { ToolingDeps, ToolingDevice } from "./deploy";
83
+ import {
84
+ buildLaunchUrl,
85
+ directoryExists,
86
+ loadToolingDeps,
87
+ normalizeKeyOrThrow,
88
+ postNoBody,
89
+ readAppTypeFromManifest,
90
+ requestServerBuild,
91
+ resolveAppPath
92
+ } from "./deploy";
93
+ import { EcpRequestQueue } from "./ecp-queue";
94
+ import { isEcpServerError, isTransientEcpError, retryOnTransient } from "./ecp-retry";
95
+ import { markDeviceFatal, markDeviceUnhealthy } from "./health";
96
+ import { asNodeTuple, isHomeScreenActiveApp, parseActiveApp, parseDeviceInfo, tupleToAppUiNode } from "./parsers";
97
+ import { resolveServerUrl as resolveServerUrlShared } from "./server-url";
98
+
99
+ function resolveServerUrl(optsServerUrl?: string): string {
100
+ return resolveServerUrlShared({ serverUrl: optsServerUrl });
101
+ }
102
+
103
+ export class RkClient {
104
+ public readonly keyDelay: number;
105
+ public readonly deviceIp: string;
106
+
107
+ private readonly ecp: EcpClient;
108
+ private readonly queue: EcpRequestQueue;
109
+ private readonly requestTimeoutMs: number;
110
+ private readonly serverUrl: string;
111
+ private readonly logSession: LogSessionLike;
112
+ private _cachedDeviceInfo: DeviceInfo | null = null;
113
+
114
+ constructor(opts: RkClientOptions = {}) {
115
+ if (!opts.deviceIp) {
116
+ throw new RkClientError(
117
+ `RkClient requires a deviceIp. Use createDefaultClient() for env-driven resolution.`,
118
+ "RK_DEVICE_IP_UNRESOLVED"
119
+ );
120
+ }
121
+ this.deviceIp = opts.deviceIp;
122
+ this.keyDelay = opts.keyDelay ?? DEFAULT_KEY_DELAY;
123
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
124
+ this.serverUrl = resolveServerUrl(opts.serverUrl);
125
+
126
+ this.ecp = new EcpClient(this.deviceIp, this.requestTimeoutMs);
127
+ this.queue = new EcpRequestQueue({ ...ECP_INTERVAL_MS, ...opts.intervals });
128
+ // Construction is free (no I/O); first `ensureStarted()` POSTs
129
+ // `/logs/start` and opens the SSE tail.
130
+ this.logSession =
131
+ opts.logSession ??
132
+ new LogSession({
133
+ serverUrl: this.serverUrl,
134
+ device: this.deviceIp,
135
+ port: ROKU_LOG_PORT
136
+ });
137
+ }
138
+
139
+ /** Accessor for the active `rk server` URL (for setup helpers). */
140
+ getServerUrl(): string {
141
+ return this.serverUrl;
142
+ }
143
+
144
+ /** Lazy-open the log session against `rk server`. Idempotent. */
145
+ async ensureLogSession(): Promise<void> {
146
+ await this.logSession.ensureStarted();
147
+ }
148
+
149
+ /**
150
+ * Close the local SSE subscription. Does NOT stop the server-side
151
+ * session — another worker may share it; global teardown owns that.
152
+ */
153
+ disposeLogSession(): void {
154
+ this.logSession.dispose();
155
+ }
156
+
157
+ /** Read-only accessor for the per-client `LogBuffer`. */
158
+ logBuffer(): LogBufferLike {
159
+ return this.logSession.buffer;
160
+ }
161
+
162
+ /**
163
+ * Return the last `n` buffered log entries, oldest first. Non-invasive:
164
+ * no ECP slot, no buffer mutation. On overflow, re-queries from the
165
+ * surviving `minAvailableSeq` in O(1) instead of scanning per-seq.
166
+ */
167
+ recentLogLines(n = 100): Array<{ seq: number; line: LogLine }> {
168
+ const requested = Math.max(0, Math.floor(n));
169
+ if (requested === 0) return [];
170
+ const buffer = this.logSession.buffer;
171
+ const currPos = Number.parseInt(buffer.currentPosition(), 10);
172
+ if (!Number.isFinite(currPos) || currPos <= 0) return [];
173
+ const desiredStart = Math.max(0, currPos - requested);
174
+ const tailFromStart = (start: number): Array<{ seq: number; line: LogLine }> => {
175
+ const entries = buffer.entriesSince(String(start));
176
+ const tail = entries.slice(Math.max(0, entries.length - requested));
177
+ return tail.map(e => ({ seq: e.seq, line: e.line }));
178
+ };
179
+ try {
180
+ return tailFromStart(desiredStart);
181
+ } catch (err) {
182
+ // Overflow — retry from the surviving `minAvailableSeq`.
183
+ if (!(err instanceof LogBufferOverflowError)) throw err;
184
+ const minAvailable = Number.parseInt(err.minAvailable, 10);
185
+ if (!Number.isFinite(minAvailable) || minAvailable >= currPos) return [];
186
+ // Defensive: if eviction races again, return [] rather than
187
+ // throw out of a non-invasive accessor.
188
+ try {
189
+ return tailFromStart(minAvailable);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Snapshot of client-side gap accounting plus server-side reconnect
198
+ * metrics (`GET /api/logs/list`). Network failures yield
199
+ * `serverMetrics: null` — a reporter must never throw out of a
200
+ * diagnostic surface. Non-invasive: no ECP slot, no buffer mutation.
201
+ */
202
+ async getLogStreamHealth(): Promise<LogStreamHealth> {
203
+ const buffer = this.logSession.buffer;
204
+ const stats = buffer.getStats();
205
+ const base: LogStreamHealth = {
206
+ gapCount: stats.gapCount,
207
+ totalGapSeqSpan: stats.totalGapSeqSpan,
208
+ lastGapAt: stats.lastGapAt,
209
+ openGap: stats.openGap
210
+ };
211
+
212
+ // `serverUrl` is normalized to end with `/api`; append `/logs/list`
213
+ // only. Server keys sessions by (device, port); filter on deviceIp.
214
+ const url = `${trimTrailingSlash(this.serverUrl)}/logs/list`;
215
+ let res: Response;
216
+ try {
217
+ res = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
218
+ } catch {
219
+ return { ...base, serverMetrics: null };
220
+ }
221
+ if (!res.ok) {
222
+ return { ...base, serverMetrics: null };
223
+ }
224
+ let body: unknown;
225
+ try {
226
+ body = await res.json();
227
+ } catch {
228
+ return { ...base, serverMetrics: null };
229
+ }
230
+ const sessions = isObject(body) && Array.isArray(body.sessions) ? body.sessions : [];
231
+ const matched = sessions.find(
232
+ (s): s is Record<string, unknown> => isObject(s) && (s.ip === this.deviceIp || s.device === this.deviceIp)
233
+ );
234
+ if (!matched) {
235
+ // Reachable server, no matching session — `undefined` is
236
+ // distinct from `null` (network failure) for reporters.
237
+ return { ...base, serverMetrics: undefined };
238
+ }
239
+ const metrics = isObject(matched.metrics) ? matched.metrics : {};
240
+ return {
241
+ ...base,
242
+ serverMetrics: {
243
+ reconnectCount: numberOr(metrics.reconnectCount, 0),
244
+ totalDowntime: numberOr(metrics.totalDowntime, 0),
245
+ uptime: numberOr(metrics.uptime, 1),
246
+ connectedAt: stringOrNull(metrics.connectedAt),
247
+ lastActivity: stringOrNull(metrics.lastActivity)
248
+ }
249
+ };
250
+ }
251
+
252
+ // -----------------------------------------------------------------
253
+ // ECP methods
254
+ // -----------------------------------------------------------------
255
+
256
+ /**
257
+ * Fetch the SceneGraph node tree. Rate-limited at 200ms between calls.
258
+ * `bounds` is parsed to `{x,y,width,height}`; unparseable/missing
259
+ * values degrade to a zero-sized rect so callers get a shape, not a throw.
260
+ */
261
+ async queryAppUi(): Promise<AppUiNode> {
262
+ const xml = await this.runEcp("query-appui", () => this.ecp.query("/query/app-ui"));
263
+ const root = asNodeTuple(xmlToJson(xml));
264
+ if (!root) {
265
+ throw new RkClientError(
266
+ `Failed to parse /query/app-ui response from ${this.deviceIp}.`,
267
+ "ECP_APPUI_PARSE_FAILED"
268
+ );
269
+ }
270
+ return tupleToAppUiNode(root);
271
+ }
272
+
273
+ /**
274
+ * Fetch device metadata. Always re-queries ECP; result is cached on
275
+ * the instance. Use `cachedDeviceInfo()` to read without a network hit.
276
+ */
277
+ async queryDeviceInfo(): Promise<DeviceInfo> {
278
+ const xml = await this.runEcp("query-device-info", () => this.ecp.query("/query/device-info"));
279
+ const info = parseDeviceInfo(xmlToKeyValue(xml));
280
+ this._cachedDeviceInfo = info;
281
+ return info;
282
+ }
283
+
284
+ /** In-memory cached `DeviceInfo` snapshot, or null if never fetched. */
285
+ cachedDeviceInfo(): DeviceInfo | null {
286
+ return this._cachedDeviceInfo;
287
+ }
288
+
289
+ /** Prime the cache from an external source (avoids a second ECP hit per-worker). */
290
+ seedDeviceInfo(info: DeviceInfo): void {
291
+ this._cachedDeviceInfo = info;
292
+ }
293
+
294
+ /** Clear the cached `DeviceInfo`. Test-only. */
295
+ resetCache(): void {
296
+ this._cachedDeviceInfo = null;
297
+ }
298
+
299
+ /**
300
+ * Return the currently-active app, faithful to the ECP XML. The home
301
+ * screen has more than one shape across firmware, so test it with
302
+ * `isHomeScreenActiveApp` instead of comparing fields.
303
+ */
304
+ async queryActiveApp(): Promise<ActiveAppInfo> {
305
+ const xml = await this.runEcp("query-active-app", () => this.ecp.query("/query/active-app"));
306
+ const root = asNodeTuple(xmlToJson(xml));
307
+ if (!root) {
308
+ throw new RkClientError(
309
+ `Failed to parse /query/active-app response from ${this.deviceIp}.`,
310
+ "ECP_ACTIVEAPP_PARSE_FAILED"
311
+ );
312
+ }
313
+ return parseActiveApp(root);
314
+ }
315
+
316
+ /** PNG screenshot bytes; rate-limited at 500ms between captures. */
317
+ async captureScreenshot(): Promise<Buffer> {
318
+ return this.runEcp("capture", () => this.ecp.captureScreen("png"));
319
+ }
320
+
321
+ /**
322
+ * Validate and send one keypress. Invalid keys throw before hitting
323
+ * the queue so a typo in a test doesn't consume a slot.
324
+ */
325
+ async keypress(key: string): Promise<void> {
326
+ const normalized = normalizeKeyOrThrow(key);
327
+ await this.runEcp("keypress", async () => {
328
+ await this.ecp.keypress(normalized);
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Send a sequence of keypresses with an inter-key delay (between end
334
+ * of one and start of next). All keys validated up-front so a typo
335
+ * deep in the sequence fails before any input is sent.
336
+ */
337
+ async keysequence(keys: string[], opts?: KeySequenceOptions): Promise<void> {
338
+ const normalized = keys.map(normalizeKeyOrThrow);
339
+ const delay = opts?.keyDelay ?? this.keyDelay;
340
+ for (let i = 0; i < normalized.length; i++) {
341
+ await this.runEcp("keypress", async () => {
342
+ await this.ecp.keypress(normalized[i]!);
343
+ });
344
+ if (i < normalized.length - 1 && delay > 0) {
345
+ await sleepUnref(delay);
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Launch a channel via ECP `/launch/{channelId}` (defaults to `"dev"`),
352
+ * then block on the shared readiness + system-dialog gate. All failures
353
+ * surface as `DeployFailedError` with the device marked unhealthy.
354
+ */
355
+ async launch(opts?: LaunchOptions): Promise<void> {
356
+ const channelId = opts?.channelId ?? "dev";
357
+ const timeout = opts?.readinessTimeout ?? READINESS_TIMEOUT_MS;
358
+ try {
359
+ await this.launchAndAwaitReady(channelId, opts?.deepLink, timeout, opts?.readyNode);
360
+ } catch (err) {
361
+ // Readiness/dialog failures are already wrapped (health flag set);
362
+ // re-throw to avoid double-marking or losing structured context.
363
+ if (err instanceof DeployFailedError) throw err;
364
+ const reason = `ECP launch failed: ${errorMessage(err)}`;
365
+ markDeviceUnhealthy(`launch: ${reason}`);
366
+ throw new DeployFailedError(`Deploy failed: ${reason}`, {
367
+ deviceIp: this.deviceIp,
368
+ expectedChannelId: channelId,
369
+ phase: "launch",
370
+ cause: err
371
+ });
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Shared launch path: POST `/launch/{channelId}` via the queue, then
377
+ * block on the readiness + dialog gate. HTTP errors propagate verbatim
378
+ * unless the auto-launch fallback below absorbs them; readiness/dialog
379
+ * failures already arrive as `DeployFailedError`.
380
+ */
381
+ private async launchAndAwaitReady(
382
+ channelId: string,
383
+ deepLink: Record<string, string> | undefined,
384
+ timeoutMs: number,
385
+ readyNode?: NodePredicate
386
+ ): Promise<void> {
387
+ const url = buildLaunchUrl(this.deviceIp, channelId, deepLink);
388
+ try {
389
+ await this.runEcp("launch", async () => {
390
+ await postNoBody(url, this.requestTimeoutMs);
391
+ });
392
+ } catch (err) {
393
+ // Newer firmware auto-launches sideloaded `dev` builds after
394
+ // `replace()`, so the POST races a launch that is already
395
+ // under way. For plain sideload (no deep-link), fall back to
396
+ // waiting on the auto-launched app instead of treating the
397
+ // POST verdict as fatal.
398
+ if (channelId === "dev" && deepLink === undefined && isAutoLaunchTolerableError(err)) {
399
+ await this.awaitReadyAndCheckDialog({ channelId, timeoutMs, readyNode });
400
+ return;
401
+ }
402
+ throw err;
403
+ }
404
+ await this.awaitReadyAndCheckDialog({ channelId, timeoutMs, readyNode });
405
+ }
406
+
407
+ // -----------------------------------------------------------------
408
+ // Readiness & health helpers (shared by launch + buildAndDeploy)
409
+ // -----------------------------------------------------------------
410
+
411
+ /**
412
+ * Return the first root-level system-dialog child of `tree`, or null.
413
+ * Matches by tag against `SYSTEM_DIALOG_TAGS`.
414
+ */
415
+ static detectSystemDialog(tree: AppUiNode): SystemDialogNode | null {
416
+ for (const child of tree.children) {
417
+ if (SYSTEM_DIALOG_TAGS.has(child.tag)) {
418
+ return {
419
+ tag: child.tag,
420
+ title: child.attrs.title ?? "",
421
+ node: child
422
+ };
423
+ }
424
+ }
425
+ return null;
426
+ }
427
+
428
+ /** Instance-level alias for `RkClient.detectSystemDialog`. */
429
+ detectSystemDialog(tree: AppUiNode): SystemDialogNode | null {
430
+ return RkClient.detectSystemDialog(tree);
431
+ }
432
+
433
+ /**
434
+ * Single-shot crash probe used by polling loops between ticks.
435
+ * Throws `AppCrashedError` if active-app is a home surface — see
436
+ * `isHomeScreenActiveApp` for which variants that covers.
437
+ */
438
+ async assertAppAlive(context?: unknown): Promise<void> {
439
+ const active = await this.queryActiveApp();
440
+ if (isHomeScreenActiveApp(active)) {
441
+ throw new AppCrashedError(
442
+ `App crashed — active app is the home screen (id="${active.id}", title="${active.title}").`,
443
+ active,
444
+ context
445
+ );
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Block until `/query/device-info` succeeds or the budget elapses.
451
+ * At least one probe always runs (do/while). Probe errors are
452
+ * absorbed and retried; the last is included on `DeviceOfflineError`
453
+ * so reporters can distinguish ECP 5xx storms from DNS unreachable.
454
+ */
455
+ async waitForDeviceOnline(opts: WaitForDeviceOnlineOptions = {}): Promise<DeviceInfo> {
456
+ const timeoutMs = opts.timeoutMs ?? DEVICE_ONLINE_DEFAULT_TIMEOUT_MS;
457
+ const intervalMs = opts.intervalMs ?? DEVICE_ONLINE_POLL_INTERVAL_MS;
458
+ const startedAt = Date.now();
459
+ const deadline = startedAt + Math.max(0, timeoutMs);
460
+ let attempts = 0;
461
+ let lastError: unknown;
462
+
463
+ // do/while so `timeoutMs: 0` still gets exactly one attempt —
464
+ // matches the "always try once" contract of our other poll helpers.
465
+ do {
466
+ attempts += 1;
467
+ try {
468
+ return await this.queryDeviceInfo();
469
+ } catch (err) {
470
+ lastError = err;
471
+ }
472
+ const remaining = deadline - Date.now();
473
+ if (remaining <= 0) break;
474
+ await sleepUnref(Math.min(intervalMs, remaining));
475
+ } while (Date.now() < deadline);
476
+
477
+ throw new DeviceOfflineError(
478
+ `Device ${this.deviceIp} did not respond to /query/device-info within ${timeoutMs}ms (attempts=${attempts}).${
479
+ lastError ? ` Last error: ${errorMessage(lastError)}` : ""
480
+ }`,
481
+ { deviceIp: this.deviceIp, timeoutMs, attempts, lastError, reason: "ecp" }
482
+ );
483
+ }
484
+
485
+ /**
486
+ * Block until the device is ready to accept ANOTHER deploy: ECP
487
+ * (`/query/device-info` on port 8060) AND the sideload installer
488
+ * (TCP/80) are both reachable. Phases run in order — ECP first,
489
+ * installer second — so a flapping ECP doesn't waste the installer
490
+ * budget on TCP probes that will be invalidated by the next reboot.
491
+ *
492
+ * Why this exists: `waitForDeviceOnline` only proves ECP liveness.
493
+ * After a crash/reboot the installer lags ECP recovery by several
494
+ * seconds on reference hardware; a deploy issued in that window
495
+ * surfaces the misleading "Device is not reachable on the network"
496
+ * diagnostic from `sideload-diagnostics`. This method is the broader
497
+ * gate `installDeployGuard` wants between failed deploy attempts.
498
+ *
499
+ * Failure modes:
500
+ * - ECP arm exhausts → `DeviceOfflineError` with `reason: "ecp"`.
501
+ * - ECP arm passes, installer arm exhausts → `DeviceOfflineError`
502
+ * with `reason: "installer"` and `tcpStatus` set to the last
503
+ * `tcpProbe` status (`"closed" | "timeout" | "unreachable"`).
504
+ *
505
+ * On success the cached `DeviceInfo` is primed (matches
506
+ * `waitForDeviceOnline`'s contract) so callers can read it without
507
+ * an extra ECP round-trip.
508
+ */
509
+ async waitForDeviceFullyReady(opts: WaitForDeviceFullyReadyOptions = {}): Promise<DeviceInfo> {
510
+ const ecpTimeoutMs = opts.ecpTimeoutMs ?? DEVICE_ONLINE_DEFAULT_TIMEOUT_MS;
511
+ const ecpIntervalMs = opts.ecpIntervalMs ?? DEVICE_ONLINE_POLL_INTERVAL_MS;
512
+ const installerTimeoutMs = opts.installerTimeoutMs ?? INSTALLER_PORT_PROBE_DEFAULT_TIMEOUT_MS;
513
+ const installerIntervalMs = opts.installerIntervalMs ?? INSTALLER_PORT_PROBE_INTERVAL_MS;
514
+
515
+ // Phase A: ECP responder. Re-throws verbatim so the existing
516
+ // `reason: "ecp"` taxonomy on `DeviceOfflineError` is preserved.
517
+ const info = await this.waitForDeviceOnline({
518
+ timeoutMs: ecpTimeoutMs,
519
+ intervalMs: ecpIntervalMs
520
+ });
521
+
522
+ // Phase B: installer accepts a TCP connection. `do/while` so a
523
+ // `installerTimeoutMs: 0` budget still gets one probe — matches
524
+ // `waitForDeviceOnline`'s "always try once" contract.
525
+ const phaseBStart = Date.now();
526
+ const phaseBDeadline = phaseBStart + Math.max(0, installerTimeoutMs);
527
+ let attempts = 0;
528
+ let lastStatus = "unknown";
529
+ let lastErrorMessage: string | undefined;
530
+
531
+ do {
532
+ attempts += 1;
533
+ const result = await tcpProbe(this.deviceIp, ROKU_INSTALLER_PORT, INSTALLER_PORT_TCP_TIMEOUT_MS);
534
+ lastStatus = result.status;
535
+ lastErrorMessage = result.errorMessage;
536
+ if (result.status === "open") {
537
+ return info;
538
+ }
539
+ const remaining = phaseBDeadline - Date.now();
540
+ if (remaining <= 0) break;
541
+ await sleepUnref(Math.min(installerIntervalMs, remaining));
542
+ } while (Date.now() < phaseBDeadline);
543
+
544
+ throw new DeviceOfflineError(
545
+ `Device ${this.deviceIp} ECP recovered but installer port ${ROKU_INSTALLER_PORT} did not accept TCP within ${installerTimeoutMs}ms (attempts=${attempts}, lastStatus=${lastStatus}).${
546
+ lastErrorMessage ? ` Last error: ${lastErrorMessage}` : ""
547
+ }`,
548
+ {
549
+ deviceIp: this.deviceIp,
550
+ timeoutMs: installerTimeoutMs,
551
+ attempts,
552
+ reason: "installer",
553
+ tcpStatus: lastStatus
554
+ }
555
+ );
556
+ }
557
+
558
+ /**
559
+ * Wait until the app is interactive, then check for system dialogs.
560
+ *
561
+ * Each poll requires (a) `/query/active-app` matches `channelId` AND
562
+ * (b) the predicate (caller-supplied `readyNode`, or `children.length > 0`
563
+ * fallback) passes on the same iteration — avoids one-tick races where
564
+ * the app is launching but the scene has not rendered.
565
+ *
566
+ * Stability: requires `READINESS_CONFIRM_POLLS` consecutive matches
567
+ * before declaring ready, catching the "crash shortly after splash"
568
+ * pattern. Home-screen responses are tolerated while we have not
569
+ * seen any evidence that the app launched; slow sideload→launch
570
+ * handoffs are indistinguishable from a never-started app until
571
+ * the full readiness budget expires. Once we HAVE seen app-owned
572
+ * evidence, a persistent post-grace Home response is classified as
573
+ * crashed-after-ready.
574
+ */
575
+ private async awaitReadyAndCheckDialog(opts: {
576
+ channelId: string;
577
+ timeoutMs: number;
578
+ readyNode?: NodePredicate;
579
+ }): Promise<void> {
580
+ const startedAt = Date.now();
581
+ const deadline = startedAt + opts.timeoutMs;
582
+ // Race-guard: tolerate home-screen for the leading grace fraction
583
+ // of the budget (with an absolute floor) so a tiny readiness budget
584
+ // cannot collapse the grace below the observed transition window.
585
+ const homeScreenGraceMs = Math.max(
586
+ HOME_SCREEN_GRACE_MIN_MS,
587
+ Math.floor(opts.timeoutMs * HOME_SCREEN_GRACE_FRACTION)
588
+ );
589
+ let lastTree: AppUiNode | undefined;
590
+ let lastActiveApp: ActiveAppInfo | undefined;
591
+ // Consecutive predicate matches required to declare ready;
592
+ // the follow-ups catch crashes right after first render.
593
+ let consecutiveMatches = 0;
594
+ // Sticky: did we ever observe a predicate match in this run?
595
+ // Stays true across home-screen-induced counter resets so the
596
+ // post-grace branch can distinguish "slow or never launched"
597
+ // (no prior match, keep polling until timeout) from
598
+ // "crashed-after-ready" (had a prior match). Required because
599
+ // Roku's InstantOn pre-warm transiently surfaces the app+tree
600
+ // before the real launch starts; the intervening home-screen
601
+ // window must not be misclassified as a crash.
602
+ let hadAnyMatch = false;
603
+ // Sticky: did we ever observe this channel at all, even if the
604
+ // caller's readyNode never matched. Negative readyNode tests and
605
+ // slow app boots can render a real app tree, then transiently hop
606
+ // through Home before the deadline. That is not "never left home";
607
+ // keep polling so the final error preserves the readiness-timeout
608
+ // contract with the last app tree attached.
609
+ let hadChannelEvidence = false;
610
+ // Stronger than `hadChannelEvidence`: active-app or plugin-tree
611
+ // explicitly identified the target channel. Once this is true,
612
+ // a later Home report is a crash signal rather than active-app lag.
613
+ let hadActiveChannelEvidence = false;
614
+
615
+ const treeContainsChannel = (node: AppUiNode, channelId: string): boolean => {
616
+ if (node.tag === "plugin" && node.attrs?.id === channelId) {
617
+ return true;
618
+ }
619
+ return node.children.some(child => treeContainsChannel(child, channelId));
620
+ };
621
+
622
+ while (Date.now() < deadline) {
623
+ const active = await this.queryActiveApp();
624
+ lastActiveApp = active;
625
+
626
+ // channelId "dev" matches when active.id === "dev" (sideloaded).
627
+ // For published channels we accept exact id match.
628
+ const idMatches = active.id === opts.channelId;
629
+ let tree: AppUiNode | undefined;
630
+ let channelVisibleInTree = false;
631
+ if (idMatches || isHomeScreenActiveApp(active)) {
632
+ try {
633
+ tree = await this.queryAppUi();
634
+ lastTree = tree;
635
+ // Some firmware lags `/query/active-app` behind the
636
+ // SceneGraph tree during sideload launch — trust the
637
+ // tree as a stronger signal than active-app.
638
+ channelVisibleInTree = treeContainsChannel(tree, opts.channelId);
639
+ if (idMatches || channelVisibleInTree) {
640
+ hadChannelEvidence = true;
641
+ hadActiveChannelEvidence = true;
642
+ }
643
+ } catch (err) {
644
+ // Sparse XML during the launch→first-commit window is
645
+ // expected; any other error must propagate.
646
+ if (err instanceof RkClientError && err.code === "ECP_APPUI_PARSE_FAILED") {
647
+ consecutiveMatches = 0;
648
+ await sleepUnref(READINESS_POLL_INTERVAL_MS);
649
+ continue;
650
+ }
651
+ throw err;
652
+ }
653
+ }
654
+
655
+ const predicateMatches = tree
656
+ ? opts.readyNode
657
+ ? tryFindNode(tree, opts.readyNode) !== null
658
+ : tree.children.length > 0
659
+ : false;
660
+ // On some firmware `/query/active-app` can briefly report Home
661
+ // while `/query/app-ui` has already switched to the sideloaded
662
+ // SceneGraph tree. A caller-provided readyNode is app-specific
663
+ // evidence, so trust it as a stronger signal than active-app.
664
+ const readyNodeVisibleWhileHome = Boolean(
665
+ opts.readyNode && isHomeScreenActiveApp(active) && predicateMatches && !hadActiveChannelEvidence
666
+ );
667
+ if (tree && (idMatches || channelVisibleInTree || readyNodeVisibleWhileHome)) {
668
+ if (predicateMatches) {
669
+ // Dialog check on every match: a dialog appearing AFTER
670
+ // first-render must still fail the deploy.
671
+ const dialog = RkClient.detectSystemDialog(tree);
672
+ if (dialog) {
673
+ const reason = `system dialog detected: ${dialog.tag}${dialog.title ? ` ("${dialog.title}")` : ""}`;
674
+ markDeviceUnhealthy(reason);
675
+ throw new DeployFailedError(`Deploy failed: ${reason}`, {
676
+ deviceIp: this.deviceIp,
677
+ expectedChannelId: opts.channelId,
678
+ phase: "readiness",
679
+ reason: "system-dialog",
680
+ dialog,
681
+ lastTree: tree,
682
+ lastActiveApp: active
683
+ });
684
+ }
685
+ consecutiveMatches += 1;
686
+ hadAnyMatch = true;
687
+ hadChannelEvidence = true;
688
+ if (consecutiveMatches >= READINESS_CONFIRM_POLLS) {
689
+ return;
690
+ }
691
+ } else {
692
+ // Reset the stability counter on any miss.
693
+ consecutiveMatches = 0;
694
+ }
695
+ } else {
696
+ // Home screen here = either a slow/never launch (no prior
697
+ // app-owned evidence) or crashed-after-ready (had a prior
698
+ // match). Crash decisions are deferred until we are past
699
+ // the grace window: Roku's InstantOn pre-warm transiently
700
+ // exits the app back to the launcher before the real
701
+ // launch starts, and we MUST tolerate that flip-flop
702
+ // for the leading slice of the readiness budget. With no
703
+ // prior evidence, keep polling until the full timeout; in
704
+ // CI, cold sideloads can stay on Home beyond the
705
+ // grace window and still launch successfully.
706
+ if (isHomeScreenActiveApp(active)) {
707
+ const pastGrace = Date.now() - startedAt >= homeScreenGraceMs;
708
+ if (pastGrace) {
709
+ if (hadAnyMatch) {
710
+ const reason = `app crashed between first render and stability confirmation (active.id="${active.id}", title="${active.title}")`;
711
+ markDeviceUnhealthy(`deploy: ${reason}`);
712
+ throw new DeployFailedError(`Deploy failed: ${reason}`, {
713
+ deviceIp: this.deviceIp,
714
+ expectedChannelId: opts.channelId,
715
+ phase: "readiness",
716
+ reason: "crashed-after-ready",
717
+ lastTree,
718
+ lastActiveApp: active
719
+ });
720
+ }
721
+ if (hadChannelEvidence && opts.readyNode) {
722
+ // The app launched but never satisfied the
723
+ // caller's readiness predicate. Do not relabel
724
+ // that as "never-left-home" just because the
725
+ // firmware reports Home on a later poll.
726
+ consecutiveMatches = 0;
727
+ await sleepUnref(READINESS_POLL_INTERVAL_MS);
728
+ continue;
729
+ }
730
+ }
731
+ // Within grace: home screen is a transient handoff
732
+ // (InstantOn pre-warm exit, or sideload→launch
733
+ // window). Reset stability and keep polling — a
734
+ // subsequent real launch can still pass the gate.
735
+ }
736
+ // Active is some other app (launcher, different channel)
737
+ // or home-within-grace — reset stability counter; any
738
+ // prior tentative match is invalid for stability.
739
+ consecutiveMatches = 0;
740
+ }
741
+
742
+ await sleepUnref(READINESS_POLL_INTERVAL_MS);
743
+ }
744
+
745
+ if (lastActiveApp && isHomeScreenActiveApp(lastActiveApp) && !hadChannelEvidence) {
746
+ const reason = `app never left home screen after launch (active.id="${lastActiveApp.id}", title="${lastActiveApp.title}")`;
747
+ // FATAL, not merely unhealthy: the device answered ECP for the
748
+ // whole readiness budget but never foregrounded the sideloaded
749
+ // channel. ECP/installer liveness recovery cannot cure a
750
+ // foreground-incapable VM, so latch the process-wide fatal flag
751
+ // — the deploy-guard and recovery hook short-circuit on it
752
+ // instead of retrying every remaining suite against a dead VM.
753
+ markDeviceFatal(`deploy: app never left home screen after launch`);
754
+ throw new DeployFailedError(`Deploy failed: ${reason}`, {
755
+ deviceIp: this.deviceIp,
756
+ expectedChannelId: opts.channelId,
757
+ phase: "readiness",
758
+ reason: "never-left-home",
759
+ lastTree,
760
+ lastActiveApp
761
+ });
762
+ }
763
+
764
+ const detail = opts.readyNode
765
+ ? `predicate ${JSON.stringify(opts.readyNode)} never matched`
766
+ : `no children in tree`;
767
+ const reason = `readiness timeout after ${opts.timeoutMs}ms waiting for active-app="${opts.channelId}" (${detail})`;
768
+ markDeviceUnhealthy(reason);
769
+ throw new DeployFailedError(`Deploy failed: ${reason}`, {
770
+ deviceIp: this.deviceIp,
771
+ expectedChannelId: opts.channelId,
772
+ phase: "readiness",
773
+ reason: "readiness-timeout",
774
+ lastTree,
775
+ lastActiveApp
776
+ });
777
+ }
778
+
779
+ // -----------------------------------------------------------------
780
+ // Polling helpers (waitForNode + log/screenshot convergence)
781
+ // -----------------------------------------------------------------
782
+
783
+ /**
784
+ * Poll `queryAppUi()` until `predicate` matches or `opts.timeout`
785
+ * elapses. Each iteration: crash probe → fresh tree → match attempt.
786
+ *
787
+ * `opts.interval` is a gap-based minimum (iteration-start to next
788
+ * iteration-start), not a fixed sleep, so we don't double-sleep on
789
+ * top of the ECP queue's 200ms cadence. On timeout, throws
790
+ * `WaitForNodeTimeoutError` carrying the last tree for diagnostics.
791
+ */
792
+ async waitForNode(predicate: NodePredicate, opts: WaitOptions): Promise<AppUiNode> {
793
+ const interval = opts.interval ?? WAIT_FOR_NODE_DEFAULT_INTERVAL_MS;
794
+ const timeoutMs = opts.timeout;
795
+ const deadline = Date.now() + timeoutMs;
796
+ let lastTree: AppUiNode | null = null;
797
+
798
+ // do/while so a single iteration runs even at `timeoutMs <= 0`,
799
+ // populating `lastTree` for the timeout diagnostic.
800
+ do {
801
+ const iterStart = Date.now();
802
+ await this.assertAppAlive({ operation: "waitForNode", predicate });
803
+ // Errors from queryAppUi propagate verbatim — callers need the
804
+ // structured RkClientError/ToolingError code to react.
805
+ lastTree = await this.queryAppUi();
806
+ const match = tryFindNode(lastTree, predicate);
807
+ if (match) return match;
808
+
809
+ const now = Date.now();
810
+ const remaining = deadline - now;
811
+ if (remaining <= 0) break;
812
+
813
+ // Gap-based: only sleep for the slack between iteration
814
+ // duration and the caller's desired minimum.
815
+ const slack = interval - (now - iterStart);
816
+ if (slack > 0) {
817
+ await sleepUnref(Math.min(slack, remaining));
818
+ } else if (interval === 0) {
819
+ // 1ms yield avoids tight-spin when both `interval` and the
820
+ // queue gap are 0; smallest value guaranteed to post-pone
821
+ // the next iteration across Node/jsdom/browser envs.
822
+ await sleepUnref(1);
823
+ }
824
+ } while (Date.now() < deadline);
825
+
826
+ const preview = lastTree ? `\nLast tree (summary):\n${summarizeTree(lastTree, 2, 600)}` : "";
827
+ throw new WaitForNodeTimeoutError(
828
+ `waitForNode timed out after ${timeoutMs}ms waiting for ${JSON.stringify(predicate)}.${preview}`,
829
+ { predicate, timeoutMs, lastTree }
830
+ );
831
+ }
832
+
833
+ /**
834
+ * Opaque snapshot of the current log-stream position. Synchronous so
835
+ * callers can grab a baseline before an action and prove causality
836
+ * with a follow-up `waitForLog`. A fresh client reports `"0"` even
837
+ * before the session has attached.
838
+ */
839
+ logPosition(): LogPosition {
840
+ return this.logSession.buffer.currentPosition();
841
+ }
842
+
843
+ /**
844
+ * Wait for a line matching `pattern` at or after `opts.since`.
845
+ * Buffered matches outside any gap resolve immediately; otherwise
846
+ * we subscribe for the first live clean match. On timeout, a gap
847
+ * within the wait window surfaces as `LogStreamGapError` (we can't
848
+ * disambiguate "marker fell into the gap"); otherwise
849
+ * `WaitForLogTimeoutError` with the recent-lines preview.
850
+ *
851
+ * Matches whose seq is itself inside a gap are skipped (the line
852
+ * could be partial/concatenated at the reconnect boundary).
853
+ * `expectNoLog` is the asymmetric strict counterpart: absence is
854
+ * unprovable across a gap.
855
+ */
856
+ async waitForLog(pattern: RegExp, opts: LogQueryOptions): Promise<LogLine> {
857
+ await this.ensureLogSession();
858
+ const buffer = this.logSession.buffer;
859
+ const since = opts.since;
860
+ const timeoutMs = opts.timeout ?? WAIT_FOR_LOG_DEFAULT_TIMEOUT_MS;
861
+
862
+ // Existing-match scan: accept the first match whose seq is
863
+ // outside every recorded gap; fall through to the live wait
864
+ // if none clean exists.
865
+ const existing = buffer.entriesSince(since);
866
+ let linesChecked = 0;
867
+ for (const e of existing) {
868
+ linesChecked += 1;
869
+ if (pattern.test(e.line)) {
870
+ const gap = buffer.findGapContaining(e.seq);
871
+ if (gap) {
872
+ // Suspect line at a reconnect boundary — keep scanning.
873
+ continue;
874
+ }
875
+ return e.line;
876
+ }
877
+ }
878
+
879
+ return new Promise<LogLine>((resolve, reject) => {
880
+ let settled = false;
881
+ const recent: LogLine[] = [];
882
+ const settle = (fn: () => void): void => {
883
+ if (settled) return;
884
+ settled = true;
885
+ unsubscribe();
886
+ clearTimeout(timer);
887
+ fn();
888
+ };
889
+ const unsubscribe = buffer.subscribe(entry => {
890
+ if (settled) return;
891
+ linesChecked += 1;
892
+ recent.push(entry.line);
893
+ if (recent.length > WAIT_FOR_LOG_RECENT_LINES_MAX) {
894
+ recent.shift();
895
+ }
896
+ if (pattern.test(entry.line)) {
897
+ // Skip matches inside a gap (line may be partial); a
898
+ // later clean line may still satisfy the assertion.
899
+ const gap = buffer.findGapContaining(entry.seq);
900
+ if (gap) return;
901
+ settle(() => resolve(entry.line));
902
+ }
903
+ });
904
+ const timer = setTimeout(() => {
905
+ // A gap inside the window makes a non-result ambiguous;
906
+ // surface the gap rather than a bare timeout.
907
+ const gap = buffer.hasGapWithin(since);
908
+ if (gap) {
909
+ settle(() =>
910
+ reject(
911
+ new LogStreamGapError(gapMessage("waitForLog", pattern, gap), {
912
+ gapStart: String(gap.start),
913
+ gapEnd: String(gap.end)
914
+ })
915
+ )
916
+ );
917
+ return;
918
+ }
919
+ const preview = recentLinesPreview(recent);
920
+ const msg =
921
+ `waitForLog timed out after ${timeoutMs}ms waiting for ${pattern} ` +
922
+ `(since=${since}, linesChecked=${linesChecked})${preview ? `\nRecent lines:\n${preview}` : ""}`;
923
+ settle(() =>
924
+ reject(
925
+ new WaitForLogTimeoutError(msg, {
926
+ pattern: String(pattern),
927
+ since,
928
+ timeoutMs,
929
+ linesChecked,
930
+ recentLines: recent.slice()
931
+ })
932
+ )
933
+ );
934
+ }, timeoutMs);
935
+ // Don't keep the event loop alive purely for this timer.
936
+ if (typeof (timer as { unref?: () => unknown }).unref === "function") {
937
+ (timer as { unref: () => unknown }).unref();
938
+ }
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Bulk read of every line with seq >= parseInt(opts.since). Does not
944
+ * subscribe. Throws `LogBufferOverflowError` if `since` predates
945
+ * the retention window.
946
+ */
947
+ async logs(opts: { since: LogPosition }): Promise<LogLine[]> {
948
+ await this.ensureLogSession();
949
+ return this.logSession.buffer.linesSince(opts.since);
950
+ }
951
+
952
+ /**
953
+ * Fail if `pattern` matches any line within `opts.window` ms of "now"
954
+ * starting from `opts.since`. Pre-existing or live match throws
955
+ * `UnexpectedLogError`; any gap overlapping the window throws
956
+ * `LogStreamGapError` (absence is unprovable across a gap).
957
+ * `opts.timeout` is accepted as an alias for `opts.window`.
958
+ */
959
+ async expectNoLog(pattern: RegExp, opts: LogQueryOptions): Promise<void> {
960
+ await this.ensureLogSession();
961
+ const buffer = this.logSession.buffer;
962
+ const since = opts.since;
963
+ const windowMs = opts.window ?? opts.timeout;
964
+ if (typeof windowMs !== "number" || windowMs < 0) {
965
+ throw new RkClientError(
966
+ `expectNoLog requires opts.window (ms) — received: ${JSON.stringify(opts)}`,
967
+ "EXPECT_NO_LOG_INVALID_WINDOW"
968
+ );
969
+ }
970
+
971
+ for (const e of buffer.entriesSince(since)) {
972
+ if (pattern.test(e.line)) {
973
+ throw new UnexpectedLogError(
974
+ `expectNoLog: pattern ${pattern} matched existing line "${truncate(e.line, 200)}" at seq ${e.seq}.`,
975
+ { pattern: String(pattern), matchedLine: e.line, since }
976
+ );
977
+ }
978
+ }
979
+ // A pre-existing gap already makes the window inconclusive.
980
+ const initialGap = buffer.hasGapWithin(since);
981
+ if (initialGap) {
982
+ throw new LogStreamGapError(gapMessage("expectNoLog", pattern, initialGap), {
983
+ gapStart: String(initialGap.start),
984
+ gapEnd: String(initialGap.end)
985
+ });
986
+ }
987
+
988
+ return new Promise<void>((resolve, reject) => {
989
+ let settled = false;
990
+ const settle = (fn: () => void): void => {
991
+ if (settled) return;
992
+ settled = true;
993
+ unsubscribe();
994
+ clearTimeout(timer);
995
+ fn();
996
+ };
997
+ const unsubscribe = buffer.subscribe(entry => {
998
+ if (settled) return;
999
+ // A mid-window gap fails immediately: cannot prove the
1000
+ // negative across dropped lines.
1001
+ const gap = buffer.hasGapWithin(since);
1002
+ if (gap) {
1003
+ settle(() =>
1004
+ reject(
1005
+ new LogStreamGapError(gapMessage("expectNoLog", pattern, gap), {
1006
+ gapStart: String(gap.start),
1007
+ gapEnd: String(gap.end)
1008
+ })
1009
+ )
1010
+ );
1011
+ return;
1012
+ }
1013
+ if (pattern.test(entry.line)) {
1014
+ settle(() =>
1015
+ reject(
1016
+ new UnexpectedLogError(
1017
+ `expectNoLog: pattern ${pattern} matched live line "${truncate(entry.line, 200)}" at seq ${entry.seq}.`,
1018
+ {
1019
+ pattern: String(pattern),
1020
+ matchedLine: entry.line,
1021
+ since
1022
+ }
1023
+ )
1024
+ )
1025
+ );
1026
+ }
1027
+ });
1028
+ const timer = setTimeout(() => {
1029
+ // Catch a gap that closed silently during the wait with
1030
+ // no intervening line.
1031
+ const gap = buffer.hasGapWithin(since);
1032
+ if (gap) {
1033
+ settle(() =>
1034
+ reject(
1035
+ new LogStreamGapError(gapMessage("expectNoLog", pattern, gap), {
1036
+ gapStart: String(gap.start),
1037
+ gapEnd: String(gap.end)
1038
+ })
1039
+ )
1040
+ );
1041
+ return;
1042
+ }
1043
+ settle(resolve);
1044
+ }, windowMs);
1045
+ if (typeof (timer as { unref?: () => unknown }).unref === "function") {
1046
+ (timer as { unref: () => unknown }).unref();
1047
+ }
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Build the app at `appPath`, sideload the resulting `pkg-dir`,
1053
+ * launch, and block until interactive. Failures split into
1054
+ * `BuildFailedError` (host-side; device not flagged) vs
1055
+ * `DeployFailedError` (device-side; device flagged unhealthy so
1056
+ * later `beforeEach` hooks can fail fast).
1057
+ *
1058
+ * Deep-link caveat: passing `opts.deepLink` on a cold sideload is
1059
+ * best-effort — the `Install` ECP auto-launches without params and
1060
+ * the follow-up `/launch/?…` races the runtime's handler
1061
+ * registration. If correctness depends on params landing, use
1062
+ * `buildAndLaunchWithParams` which gates on a runtime-ready log line.
1063
+ */
1064
+ async buildAndDeploy(appPath: string, opts?: BuildAndDeployOptions): Promise<void> {
1065
+ const absPath = resolveAppPath(appPath);
1066
+ if (!directoryExists(absPath)) {
1067
+ throw new BuildFailedError(
1068
+ `buildAndDeploy: app path does not exist or is not a directory: ${absPath}`,
1069
+ absPath
1070
+ );
1071
+ }
1072
+ const configFile = join(absPath, "roku-config.ts");
1073
+ if (!existsSync(configFile)) {
1074
+ throw new BuildFailedError(
1075
+ `buildAndDeploy: no roku-config.ts at ${configFile}. Mode A expects the app's own config; Mode B expects the fixture's roku-config.ts to export makeConfig(...).`,
1076
+ absPath
1077
+ );
1078
+ }
1079
+
1080
+ // The build runs server-side via `requestServerBuild`, so we do
1081
+ // not need esbuild loaded in this process — keeps ~200 MiB of
1082
+ // orphan `esbuild --service` procs out of the build container.
1083
+ // `loadToolingDeps()` uses `require()` (not dynamic `import()`)
1084
+ // because TS5.9 parse fails on esbuild's `.d.cts` types
1085
+ // (transitive `@jridgewell/remapping` ESM-in-CJS syntax) — and
1086
+ // `skipLibCheck` cannot help since parse precedes check.
1087
+ const tooling = loadToolingDeps();
1088
+
1089
+ // Per-integration-test out-dir keeps artifacts out of the app's
1090
+ // own `dist-build` so local `rk build` runs don't collide.
1091
+ const outDir = "dist-integration-test";
1092
+ let pkgDir: string;
1093
+ // The build is memoised per-process via `getOrCreateBuildPromise`:
1094
+ // when the same `(absPath, outDir, buildType, dropConsole)` tuple
1095
+ // appears across multiple `beforeAll` deploys in one Jest run —
1096
+ // typical for shards that share a fixture, e.g. `runtime` reusing
1097
+
1098
+ // `apps/internal-runtime` across eight test files — rk-server is only
1099
+
1100
+ // asked to bundle once. Source files do not change between those
1101
+ // `beforeAll`s, so the second through N-th call would be pure
1102
+ // wasted wall-clock. See `build-cache.ts` for the failure /
1103
+ // invalidation contract (rejected builds drop the entry; the
1104
+ // deploy-guard wipes both `dist-integration-test/` and the
1105
+ // matching cache entry on retry).
1106
+ const buildType = process.env.ROKU_BUILD_TYPE || "build";
1107
+ const serverUrl = this.serverUrl;
1108
+ try {
1109
+ await getOrCreateBuildPromise(
1110
+ {
1111
+ absRoot: absPath,
1112
+ outDir,
1113
+ buildType,
1114
+ dropConsole: opts?.dropConsole,
1115
+ dev: opts?.dev,
1116
+ solidDevMode: opts?.solidDevMode,
1117
+ buildVariables: opts?.buildVariables
1118
+ },
1119
+ () =>
1120
+ requestServerBuild(serverUrl, {
1121
+ root: absPath,
1122
+ outDir,
1123
+ // Forward ROKU_BUILD_TYPE so the server-side build sees
1124
+ // the same fixture mode the test set; otherwise the
1125
+ // fixture's roku-config always sees the default "build".
1126
+ buildType,
1127
+ // Integration-test fixture builds only need to bundle
1128
+ // and deploy so device tests can exercise runtime APIs;
1129
+ // they must not be gated on the workspace-wide semantic
1130
+ // type check (a pre-existing type error in an unrelated
1131
+ // package would otherwise block every on-device run).
1132
+ // Temporary "for now" measure — drop once the workspace
1133
+ // build is type-clean again.
1134
+ disableTypeChecking: true,
1135
+ // Only forward `dropConsole` when explicitly set so the
1136
+ // server's arg-derivation falls back to the fixture's
1137
+ // roku-config value (parity with mergeConfig pre-server-build).
1138
+ ...(opts?.dropConsole !== undefined ? { dropConsole: opts.dropConsole } : {}),
1139
+ // Forward `dev` when set — disables minification and enables
1140
+ // source maps for profiling sessions with readable identifiers.
1141
+ ...(opts?.dev ? { dev: true } : {}),
1142
+ // Forward `solidDevMode` to enable SolidJS dev build (readable
1143
+ // signal/effect names in Perfetto tracepoints).
1144
+ ...(opts?.solidDevMode ? { solidDevMode: true } : {}),
1145
+ // Forward `buildVariables` overrides so the server can merge
1146
+ // them with the app's roku-config buildVariables (e.g.
1147
+ // { __SIGNAL__: true } enables SolidJS dev mode).
1148
+ ...(opts?.buildVariables ? { buildVariables: opts.buildVariables } : {})
1149
+ })
1150
+ );
1151
+ pkgDir = join(absPath, outDir, "pkg-dir");
1152
+ } catch (err) {
1153
+ throw new BuildFailedError(
1154
+ `Build failed for ${absPath}: ${errorMessage(err)}. Fix the host-side TypeScript/bundler/manifest errors and try again.`,
1155
+ absPath,
1156
+ err
1157
+ );
1158
+ }
1159
+
1160
+ const { ip, password } = await this.resolveTargetDevice(opts?.deviceSelector, absPath, tooling);
1161
+
1162
+ const { appType, libraryName } = await readAppTypeFromManifest(pkgDir, tooling.readManifestEntries);
1163
+
1164
+ try {
1165
+ const device = new tooling.RokuDevice(ip, password, await tooling.getTempDir());
1166
+ // Don't pass launchParams here — we run the launch ourselves
1167
+ // below so it goes through the queue and shared readiness gate.
1168
+ // Retry once on transient installer/network errors: `/plugin_install`
1169
+ // occasionally rejects or times out under rapid back-to-back sideloads.
1170
+ // If the upload was interrupted after firmware touched the dev channel,
1171
+ // best-effort delete clears the half-installed state before retrying.
1172
+ let lastErr: unknown;
1173
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1174
+ try {
1175
+ await device.replace(pkgDir, appType, libraryName);
1176
+ lastErr = undefined;
1177
+ break;
1178
+ } catch (sideloadErr) {
1179
+ lastErr = sideloadErr;
1180
+ const transient = isTransientSideloadError(sideloadErr);
1181
+ if (!transient || attempt === 1) {
1182
+ throw sideloadErr;
1183
+ }
1184
+ try {
1185
+ await device.delete();
1186
+ } catch {
1187
+ // Best effort only: a busy installer can reject cleanup,
1188
+ // but the retry still has a chance after a short pause.
1189
+ }
1190
+ await sleepUnref(2000);
1191
+ }
1192
+ }
1193
+ if (lastErr) throw lastErr;
1194
+ } catch (err) {
1195
+ let formatted: string | undefined;
1196
+ try {
1197
+ const diagnostics = await tooling.runSideloadDiagnostics(ip);
1198
+ formatted = tooling.formatSideloadDiagnostics({
1199
+ ip,
1200
+ diagnostics,
1201
+ verbose: false
1202
+ });
1203
+ } catch {
1204
+ // best-effort; diagnostics are advisory
1205
+ }
1206
+ const reason = `sideload failed: ${errorMessage(err)}`;
1207
+ invalidateBuildCacheForApp(absPath);
1208
+ markDeviceUnhealthy(reason);
1209
+ throw new DeployFailedError(`Deploy failed for ${absPath}: ${reason}${formatted ? `\n${formatted}` : ""}`, {
1210
+ appPath: absPath,
1211
+ deviceIp: ip,
1212
+ phase: "sideload",
1213
+ reason: "sideload-failed",
1214
+ diagnostics: formatted,
1215
+ cause: err
1216
+ });
1217
+ }
1218
+
1219
+ // Readiness/dialog failures are already wrapped as
1220
+ // DeployFailedError in `awaitReadyAndCheckDialog`; HTTP errors
1221
+ // from the launch POST are wrapped here for a uniform contract.
1222
+ const channelId = opts?.channelId ?? "dev";
1223
+ const readinessTimeout = opts?.readinessTimeout ?? READINESS_TIMEOUT_MS;
1224
+ try {
1225
+ await this.launchAndAwaitReady(channelId, opts?.deepLink, readinessTimeout, opts?.readyNode);
1226
+ } catch (err) {
1227
+ if (err instanceof DeployFailedError) {
1228
+ invalidateBuildCacheForApp(absPath);
1229
+ throw err;
1230
+ }
1231
+ // The auto-launch race is absorbed inside
1232
+ // `launchAndAwaitReady`; anything surfacing here is a launch
1233
+ // failure the readiness gate could not vouch for.
1234
+ const reason = `ECP launch failed: ${errorMessage(err)}`;
1235
+ invalidateBuildCacheForApp(absPath);
1236
+ markDeviceUnhealthy(`launch: ${reason}`);
1237
+ throw new DeployFailedError(`Deploy failed for ${absPath}: ${reason}`, {
1238
+ appPath: absPath,
1239
+ deviceIp: ip,
1240
+ expectedChannelId: channelId,
1241
+ phase: "launch",
1242
+ reason: "launch-failed",
1243
+ cause: err
1244
+ });
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Cold sideload + reliable deep-link delivery.
1250
+ *
1251
+ * `buildAndDeploy({ deepLink })` races the runtime's launch-params
1252
+ * handler on cold sideload (firmware drops the params-carrying POST
1253
+ * if it arrives before `setLaunchParamsUpdateHandler` runs). This
1254
+ * method splits the flow:
1255
+ *
1256
+ * 1. `buildAndDeploy` without params → firmware auto-launches.
1257
+ * 2. `waitForLog(runtimeReadyLogPattern)` → fixture must emit this
1258
+ * only after the runtime has subscribed to launch params.
1259
+ * 3. `launch({ deepLink })` → params-carrying launch hits a ready handler.
1260
+ * 4. `waitForLog(paramsAckLogPattern)` → proves the reactive graph
1261
+ * observed the params; subsequent DOM timeouts are then
1262
+ * attributable to the renderer, not to lost signal.
1263
+ *
1264
+ * Both log regexes are required: making either optional would
1265
+ * re-introduce the race this method exists to eliminate. Errors
1266
+ * flow through with their original types (no extra wrapping).
1267
+ */
1268
+ async buildAndLaunchWithParams(
1269
+ appPath: string,
1270
+ deepLink: Record<string, string>,
1271
+ opts: BuildAndLaunchWithParamsOptions
1272
+ ): Promise<void> {
1273
+ const runtimeReadyTimeout = opts.runtimeReadyTimeout ?? 5000;
1274
+ const paramsAckTimeout = opts.paramsAckTimeout ?? 5000;
1275
+ await this.ensureLogSession();
1276
+ // Tail-fallback scan: only accept matches at/after `since` (else a
1277
+ // stale match from a previous deploy would pass).
1278
+ // Flag-stripping `g`/`y` because `RegExp.prototype.test` is
1279
+ // stateful with either, which causes false negatives across
1280
+ // multiple `Array.some` probes.
1281
+ const recentTailMatches = (pattern: RegExp, since: LogPosition): boolean => {
1282
+ const safePattern = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
1283
+ let minSeq: number;
1284
+ try {
1285
+ minSeq = parsePos(since);
1286
+ } catch (err) {
1287
+ // Malformed `since` means a caller hand-rolled the token;
1288
+ // warn loudly so the impending `LogBufferOverflowError`
1289
+ // isn't misattributed to a silent tail-fallback.
1290
+ console.warn(
1291
+ `[device-test-client] tail-fallback skipped: malformed ` +
1292
+ `since position "${since}" — ${(err as Error).message}`
1293
+ );
1294
+ return false;
1295
+ }
1296
+ return this.recentLogLines(500).some(entry => entry.seq >= minSeq && safePattern.test(entry.line));
1297
+ };
1298
+ const waitForLogWithTailFallback = async (
1299
+ pattern: RegExp,
1300
+ since: LogPosition,
1301
+ timeout: number
1302
+ ): Promise<void> => {
1303
+ try {
1304
+ await this.waitForLog(pattern, { since, timeout });
1305
+ } catch (err) {
1306
+ // Only mask `LogBufferOverflowError`, and only when a
1307
+ // post-`since` tail line confirms the marker.
1308
+ if (!(err instanceof LogBufferOverflowError) || !recentTailMatches(pattern, since)) {
1309
+ throw err;
1310
+ }
1311
+ }
1312
+ };
1313
+
1314
+ // Phase 1: capture position BEFORE the deploy so a fast-emitting
1315
+ // ready log cannot race ahead of our wait window.
1316
+ const readyPos = this.logPosition();
1317
+ await this.buildAndDeploy(appPath, {
1318
+ deviceSelector: opts.deviceSelector,
1319
+ channelId: opts.channelId,
1320
+ readinessTimeout: opts.readinessTimeout,
1321
+ readyNode: opts.readyNode,
1322
+ dropConsole: opts.dropConsole
1323
+ });
1324
+ await waitForLogWithTailFallback(opts.runtimeReadyLogPattern, readyPos, runtimeReadyTimeout);
1325
+
1326
+ // Phase 2: skip the readiness gate — during a params relaunch
1327
+ // the app can hop through home/splash before rebinding, which
1328
+ // the stability check would treat as a crash. The ack log is
1329
+ // the authoritative success signal.
1330
+ const ackPos = this.logPosition();
1331
+ const channelId = opts.channelId ?? "dev";
1332
+ const launchUrl = buildLaunchUrl(this.deviceIp, channelId, deepLink);
1333
+ try {
1334
+ await this.runEcp("launch", async () => {
1335
+ await postNoBody(launchUrl, this.requestTimeoutMs);
1336
+ });
1337
+ } catch (err) {
1338
+ // The params-ack log — not the POST status — is the
1339
+ // authoritative signal here, so a POST that raced the
1340
+ // relaunch is tolerated and the ack wait below decides.
1341
+ if (!isLaunchPostRaceError(err)) throw err;
1342
+ }
1343
+ await waitForLogWithTailFallback(opts.paramsAckLogPattern, ackPos, paramsAckTimeout);
1344
+ }
1345
+
1346
+ /**
1347
+ * Resolve `{ip, password}` for the target device. Order:
1348
+ * `RK_DEVICE_IP` + `RK_DEVICE_PASSWORD` env pair (skipped when
1349
+ * `selector` is set), then `.roku/roku-dev-config.json`. When only
1350
+ * `RK_DEVICE_IP` is set, the store is searched for that IP rather
1351
+ * than the active device: `globalSetup` exports the IP it resolved
1352
+ * from the store, so a run against a stored device always arrives
1353
+ * here with the IP set and the password unset.
1354
+ * `RK_DEVICE_PASSWORD` alone, a missing device, or an IP mismatch
1355
+ * with `this.deviceIp` throws `DeployFailedError({ phase: "config" })`.
1356
+ */
1357
+ private async resolveTargetDevice(
1358
+ selector: string | undefined,
1359
+ appPath: string,
1360
+ deps: Pick<ToolingDeps, "listMergedDevices" | "findDeviceBySelector">
1361
+ ): Promise<{ ip: string; password: string }> {
1362
+ if (selector === undefined) {
1363
+ const fromEnv = this.tryResolveTargetDeviceFromEnv(appPath);
1364
+ if (fromEnv) return fromEnv;
1365
+ }
1366
+ const envIpOnly = selector === undefined ? process.env[ENV_VAR.RK_DEVICE_IP] || undefined : undefined;
1367
+ const { devices, activeId, activeRceId } = await deps.listMergedDevices(process.cwd());
1368
+ const target = selector
1369
+ ? deps.findDeviceBySelector(devices, selector)
1370
+ : envIpOnly
1371
+ ? devices.find((d: ToolingDevice) => d.ip === envIpOnly)
1372
+ : devices.find((d: ToolingDevice) => d.id === activeId);
1373
+ if (!target) {
1374
+ const reason = selector
1375
+ ? `no device matched selector "${selector}"`
1376
+ : envIpOnly
1377
+ ? `RK_DEVICE_IP (${envIpOnly}) is not a registered rk device; run \`rk device add ${envIpOnly}\` and \`rk device update ${envIpOnly} --password <pw>\`, or set RK_DEVICE_PASSWORD`
1378
+ : activeRceId
1379
+ ? `the active device ${activeRceId} is a Roku Cloud Emulator device, and device tests run on LAN devices only; set RK_DEVICE_IP + RK_DEVICE_PASSWORD or make a LAN device active`
1380
+ : "no active device configured; run `rk device add <ip>` or set RK_DEVICE_IP + RK_DEVICE_PASSWORD";
1381
+ markDeviceUnhealthy(reason);
1382
+ throw new DeployFailedError(`Deploy failed for ${appPath}: ${reason}`, {
1383
+ appPath,
1384
+ deviceIp: this.deviceIp,
1385
+ phase: "config"
1386
+ });
1387
+ }
1388
+ if (!target.password) {
1389
+ const reason = `device '${target.name || target.ip}' has no password; run \`rk device update\` to set one or set RK_DEVICE_PASSWORD`;
1390
+ markDeviceUnhealthy(reason);
1391
+ throw new DeployFailedError(`Deploy failed for ${appPath}: ${reason}`, {
1392
+ appPath,
1393
+ deviceIp: target.ip,
1394
+ phase: "config"
1395
+ });
1396
+ }
1397
+ if (target.ip !== this.deviceIp) {
1398
+ const reason = envIpOnly
1399
+ ? `RK_DEVICE_IP env var (${envIpOnly}) does not match RkClient deviceIp (${this.deviceIp}); fix one of them`
1400
+ : `.roku/ active device IP (${target.ip}) does not match RkClient deviceIp (${this.deviceIp}); fix one of them`;
1401
+ markDeviceUnhealthy(reason);
1402
+ throw new DeployFailedError(`Deploy failed for ${appPath}: ${reason}`, {
1403
+ appPath,
1404
+ deviceIp: this.deviceIp,
1405
+ phase: "config"
1406
+ });
1407
+ }
1408
+ return { ip: target.ip, password: target.password };
1409
+ }
1410
+
1411
+ /**
1412
+ * Env-var fast path: returns `{ip, password}` when both are set,
1413
+ * `null` when neither is or when only `RK_DEVICE_IP` is (the caller
1414
+ * then looks the password up in the store by that IP). Throws
1415
+ * `DeployFailedError({ phase: "config" })` when only
1416
+ * `RK_DEVICE_PASSWORD` is set (a password with no device to send it
1417
+ * to) or on an env-var IP mismatch.
1418
+ */
1419
+ private tryResolveTargetDeviceFromEnv(appPath: string): { ip: string; password: string } | null {
1420
+ const envIp = process.env[ENV_VAR.RK_DEVICE_IP] ?? "";
1421
+ const envPassword = process.env[ENV_VAR.RK_DEVICE_PASSWORD] ?? "";
1422
+ const ipSet = envIp.length > 0;
1423
+ const passwordSet = envPassword.length > 0;
1424
+ if (!passwordSet) return null;
1425
+ if (!ipSet) {
1426
+ const reason =
1427
+ "RK_DEVICE_PASSWORD is set but RK_DEVICE_IP is empty; set both env vars or neither (env-var device config requires both)";
1428
+ markDeviceUnhealthy(reason);
1429
+ throw new DeployFailedError(`Deploy failed for ${appPath}: ${reason}`, {
1430
+ appPath,
1431
+ deviceIp: this.deviceIp,
1432
+ phase: "config"
1433
+ });
1434
+ }
1435
+ if (envIp !== this.deviceIp) {
1436
+ const reason = `RK_DEVICE_IP env var (${envIp}) does not match RkClient deviceIp (${this.deviceIp}); fix one of them`;
1437
+ markDeviceUnhealthy(reason);
1438
+ throw new DeployFailedError(`Deploy failed for ${appPath}: ${reason}`, {
1439
+ appPath,
1440
+ deviceIp: this.deviceIp,
1441
+ phase: "config"
1442
+ });
1443
+ }
1444
+ return { ip: envIp, password: envPassword };
1445
+ }
1446
+
1447
+ /**
1448
+ * Poll `captureScreenshot()` until two successive captures agree
1449
+ * outside the (optional) `mask` regions (strict pixel-equality by
1450
+ * default; `opts.tolerance` relaxes the gate by up to N differing
1451
+ * pixels — see `CaptureStableOptions.tolerance`), then resolve
1452
+ * with the second (newest) PNG. Required precondition for
1453
+ * meaningful `expectScreenshot(...)` assertions.
1454
+ *
1455
+ * `interval` is gap-based (mirrors `waitForNode`); `assertAppAlive`
1456
+ * runs before every capture so a crash surfaces immediately.
1457
+ * On timeout: `ScreenshotNotStableError` with last diff stats and
1458
+ * `lastPng` for artifacts (excluded from `toJSON`).
1459
+ */
1460
+ async captureStableScreenshot(opts: CaptureStableOptions): Promise<Buffer> {
1461
+ const interval = opts.interval ?? SCREENSHOT_STABLE_DEFAULT_INTERVAL_MS;
1462
+ const timeoutMs = opts.timeout;
1463
+ const deadline = Date.now() + timeoutMs;
1464
+ const rawMasks: MaskRegion[] = opts.mask ?? [];
1465
+ const tolerance = Math.max(0, Math.floor(opts.tolerance ?? 0));
1466
+
1467
+ let prevBuf: Buffer | null = null;
1468
+ let prevPng: PNG | null = null;
1469
+ // Cached across iterations: canvas dims are fixed per-device.
1470
+ // Validated on iteration 1 so an invalid-mask bug surfaces as
1471
+ // `ScreenshotMaskError` even on `timeout: 0`.
1472
+ let normalizedMasks: MaskRegion[] | null = null;
1473
+ let iterations = 0;
1474
+ let lastDiffCount = 0;
1475
+ let lastFirstDiffPoint: { x: number; y: number } | undefined;
1476
+
1477
+ do {
1478
+ const iterStart = Date.now();
1479
+ await this.assertAppAlive({ operation: "captureStableScreenshot" });
1480
+ const nextBuf = await this.captureScreenshot();
1481
+ iterations += 1;
1482
+ // Parse once; reused as `prevPng` next iteration.
1483
+ const nextPng = PNG.sync.read(nextBuf);
1484
+
1485
+ if (normalizedMasks === null) {
1486
+ normalizedMasks = validateMasks(rawMasks, {
1487
+ w: nextPng.width,
1488
+ h: nextPng.height
1489
+ });
1490
+ }
1491
+
1492
+ if (prevPng) {
1493
+ const cmp = pngsEqualOutsideMask(prevPng, nextPng, normalizedMasks);
1494
+ // `tolerance` lets sub-pixel anti-alias jitter on
1495
+ // static text settle without us declaring the screen
1496
+ // unstable; strict equality is still the default.
1497
+ if (cmp.equal || cmp.diffCount <= tolerance) return nextBuf;
1498
+ lastDiffCount = cmp.diffCount;
1499
+ lastFirstDiffPoint = cmp.firstDiffPoint;
1500
+ }
1501
+
1502
+ prevBuf = nextBuf;
1503
+ prevPng = nextPng;
1504
+
1505
+ const now = Date.now();
1506
+ const remaining = deadline - now;
1507
+ if (remaining <= 0) break;
1508
+
1509
+ // Gap-based; queue's 500ms `capture` gap already paces us.
1510
+ const slack = interval - (now - iterStart);
1511
+ if (slack > 0) {
1512
+ await sleepUnref(Math.min(slack, remaining));
1513
+ } else if (interval === 0) {
1514
+ await sleepUnref(1);
1515
+ }
1516
+ } while (Date.now() < deadline);
1517
+
1518
+ const maskedForError = normalizedMasks ?? rawMasks;
1519
+ throw new ScreenshotNotStableError(
1520
+ `captureStableScreenshot timed out after ${timeoutMs}ms ` +
1521
+ `(${iterations} iterations, lastDiffCount=${lastDiffCount}${
1522
+ lastFirstDiffPoint ? `, firstDiffPoint=(${lastFirstDiffPoint.x},${lastFirstDiffPoint.y})` : ""
1523
+ }). Mask regions: ${JSON.stringify(maskedForError)}.`,
1524
+ {
1525
+ timeoutMs,
1526
+ iterations,
1527
+ lastDiffCount,
1528
+ maskedRegions: maskedForError,
1529
+ firstDiffPoint: lastFirstDiffPoint,
1530
+ lastPng: prevBuf ?? undefined
1531
+ }
1532
+ );
1533
+ }
1534
+
1535
+ /**
1536
+ * Run an ECP operation through the queue with the retry wrapper.
1537
+ * Retry happens inside the queue slot so the backoff does not let
1538
+ * another request slip between the two attempts.
1539
+ */
1540
+ private runEcp<T>(category: EcpCategory, op: () => Promise<T>): Promise<T> {
1541
+ return this.queue.enqueue(category, () =>
1542
+ retryOnTransient(op, {
1543
+ backoffMs: ECP_RETRY_BACKOFF_MS,
1544
+ isTransient: isTransientEcpError
1545
+ })
1546
+ );
1547
+ }
1548
+ }
1549
+
1550
+ function errorMessage(err: unknown): string {
1551
+ if (err instanceof Error) return err.message;
1552
+ try {
1553
+ return String(err);
1554
+ } catch {
1555
+ return "<unserializable error>";
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * Matches the `postNoBody` timeout message for any `/launch/...` URL —
1561
+ * with or without a deep-link query string, which the previous literal
1562
+ * `"/launch/dev timed out"` check missed.
1563
+ */
1564
+ const LAUNCH_POST_TIMEOUT_RE = /\/launch\/\S*\s+timed out/;
1565
+
1566
+ /**
1567
+ * True when the `/launch` POST raced a launch that was already under
1568
+ * way, so its verdict says nothing about whether the app came up:
1569
+ *
1570
+ * - the POST timed out while the firmware was mid-boot;
1571
+ * - the transport blipped (`fetch failed`);
1572
+ * - the launcher answered 5xx (observed as `503 Service Unavailable`
1573
+ * on virtualized hosts) because it was busy foregrounding that very
1574
+ * channel.
1575
+ *
1576
+ * Callers that tolerate these must still prove the launch another way —
1577
+ * the readiness gate or the params-ack log.
1578
+ */
1579
+ function isLaunchPostRaceError(err: unknown): boolean {
1580
+ const message = errorMessage(err);
1581
+ return LAUNCH_POST_TIMEOUT_RE.test(message) || message.includes("fetch failed") || isEcpServerError(err);
1582
+ }
1583
+
1584
+ /**
1585
+ * Phase-1 (plain sideload) superset of `isLaunchPostRaceError`: newer
1586
+ * firmware auto-launches the sideloaded `dev` build and need not serve
1587
+ * `/launch/dev` at all, so a 404 there is also no evidence of failure.
1588
+ * Deep-link launches must NOT tolerate it — a missing endpoint means
1589
+ * the params were never delivered.
1590
+ */
1591
+ function isAutoLaunchTolerableError(err: unknown): boolean {
1592
+ return isLaunchPostRaceError(err) || errorMessage(err).includes("ECP endpoint not found");
1593
+ }
1594
+
1595
+ function isTransientSideloadError(err: unknown): boolean {
1596
+ const name = err instanceof Error ? err.name : "";
1597
+ const msg = errorMessage(err);
1598
+ return (
1599
+ name === "AbortError" ||
1600
+ /AbortError/i.test(msg) ||
1601
+ /operation was aborted/i.test(msg) ||
1602
+ /Request to Roku device .* timed out/i.test(msg) ||
1603
+ /DEVICE_REQUEST_TIMEOUT/i.test(msg) ||
1604
+ /fetch failed/i.test(msg) ||
1605
+ /ECONNRESET/i.test(msg) ||
1606
+ /ETIMEDOUT/i.test(msg) ||
1607
+ /socket hang up/i.test(msg)
1608
+ );
1609
+ }
1610
+
1611
+ function recentLinesPreview(lines: LogLine[]): string {
1612
+ if (lines.length === 0) return "";
1613
+ const joined = lines.join("\n");
1614
+ if (joined.length <= WAIT_FOR_LOG_RECENT_CHARS_MAX) return joined;
1615
+ const half = Math.floor(WAIT_FOR_LOG_RECENT_CHARS_MAX / 2);
1616
+ return `${joined.slice(0, half)}\n… (${joined.length - WAIT_FOR_LOG_RECENT_CHARS_MAX} more chars) …\n${joined.slice(-half)}`;
1617
+ }
1618
+
1619
+ function gapMessage(op: "waitForLog" | "expectNoLog", pattern: RegExp, gap: { start: number; end: number }): string {
1620
+ return (
1621
+ `${op}: log stream had a gap covering seqs [${gap.start}, ${gap.end}) ` +
1622
+ `during the wait window — unable to prove the presence/absence of ${pattern}. ` +
1623
+ `This happens when \`rk server\`'s connection to the device dropped and reconnected; ` +
1624
+ `re-run the test against a stable device or widen the window.`
1625
+ );
1626
+ }
1627
+
1628
+ function truncate(s: string, max: number): string {
1629
+ if (s.length <= max) return s;
1630
+ return `${s.slice(0, max - 1)}…`;
1631
+ }
1632
+
1633
+ function trimTrailingSlash(s: string): string {
1634
+ return s.endsWith("/") ? s.slice(0, -1) : s;
1635
+ }
1636
+
1637
+ function isObject(v: unknown): v is Record<string, unknown> {
1638
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1639
+ }
1640
+
1641
+ function numberOr(v: unknown, fallback: number): number {
1642
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
1643
+ }
1644
+
1645
+ function stringOrNull(v: unknown): string | null {
1646
+ return typeof v === "string" ? v : null;
1647
+ }
1648
+
1649
+ function sleepUnref(ms: number): Promise<void> {
1650
+ return new Promise<void>(resolve => {
1651
+ const t = setTimeout(resolve, ms);
1652
+ if (typeof (t as { unref?: () => unknown }).unref === "function") {
1653
+ (t as { unref: () => unknown }).unref();
1654
+ }
1655
+ });
1656
+ }