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