@roku-sdk/device-test-client 2.18.0 → 2.19.1

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.
@@ -11,6 +11,7 @@ import {
11
11
  xmlToJson,
12
12
  xmlToKeyValue
13
13
  } from "@roku-sdk/tooling/src/device-test-helpers";
14
+ import type { DeviceTarget } from "@roku-sdk/tooling/src/tooling-utils/device-target";
14
15
  import { PNG } from "pngjs";
15
16
 
16
17
  import { summarizeTree, tryFindNode } from "../assertions/assertions";
@@ -23,11 +24,16 @@ import {
23
24
  ECP_INTERVAL_MS,
24
25
  ECP_RETRY_BACKOFF_MS,
25
26
  ENV_VAR,
27
+ FOREGROUND_FAILURE_STREAK_FATAL,
26
28
  HOME_SCREEN_GRACE_FRACTION,
27
29
  HOME_SCREEN_GRACE_MIN_MS,
28
30
  INSTALLER_PORT_PROBE_DEFAULT_TIMEOUT_MS,
29
31
  INSTALLER_PORT_PROBE_INTERVAL_MS,
30
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,
31
37
  READINESS_CONFIRM_POLLS,
32
38
  READINESS_POLL_INTERVAL_MS,
33
39
  READINESS_TIMEOUT_MS,
@@ -52,7 +58,7 @@ import {
52
58
  WaitForLogTimeoutError,
53
59
  WaitForNodeTimeoutError
54
60
  } from "../errors";
55
- import { parsePos } from "../logs/log-buffer";
61
+ import { LogBuffer, parsePos } from "../logs/log-buffer";
56
62
  import { LogSession } from "../logs/log-session";
57
63
  import type {
58
64
  ActiveAppInfo,
@@ -60,6 +66,7 @@ import type {
60
66
  BuildAndDeployOptions,
61
67
  BuildAndLaunchWithParamsOptions,
62
68
  CaptureStableOptions,
69
+ DeploymentDriver,
63
70
  DeviceInfo,
64
71
  EcpCategory,
65
72
  KeySequenceOptions,
@@ -81,6 +88,7 @@ import type {
81
88
  import { getOrCreateBuildPromise, invalidateBuildCacheForApp } from "./build-cache";
82
89
  import type { ToolingDeps, ToolingDevice } from "./deploy";
83
90
  import {
91
+ buildLaunchPath,
84
92
  buildLaunchUrl,
85
93
  directoryExists,
86
94
  loadToolingDeps,
@@ -92,7 +100,13 @@ import {
92
100
  } from "./deploy";
93
101
  import { EcpRequestQueue } from "./ecp-queue";
94
102
  import { isEcpServerError, isTransientEcpError, retryOnTransient } from "./ecp-retry";
95
- import { markDeviceFatal, markDeviceUnhealthy } from "./health";
103
+ import {
104
+ hasForegroundedChannel,
105
+ markChannelForegrounded,
106
+ markDeviceFatal,
107
+ markDeviceUnhealthy,
108
+ noteForegroundFailure
109
+ } from "./health";
96
110
  import { asNodeTuple, isHomeScreenActiveApp, parseActiveApp, parseDeviceInfo, tupleToAppUiNode } from "./parsers";
97
111
  import { resolveServerUrl as resolveServerUrlShared } from "./server-url";
98
112
 
@@ -104,7 +118,9 @@ export class RkClient {
104
118
  public readonly keyDelay: number;
105
119
  public readonly deviceIp: string;
106
120
 
107
- private readonly ecp: EcpClient;
121
+ private ecp: EcpClient;
122
+ private readonly driver: DeploymentDriver | undefined;
123
+ private driverTarget: DeviceTarget | undefined;
108
124
  private readonly queue: EcpRequestQueue;
109
125
  private readonly requestTimeoutMs: number;
110
126
  private readonly serverUrl: string;
@@ -112,23 +128,26 @@ export class RkClient {
112
128
  private _cachedDeviceInfo: DeviceInfo | null = null;
113
129
 
114
130
  constructor(opts: RkClientOptions = {}) {
115
- if (!opts.deviceIp) {
131
+ this.driver = opts.driver;
132
+ if (!opts.deviceIp && !opts.driver) {
116
133
  throw new RkClientError(
117
134
  `RkClient requires a deviceIp. Use createDefaultClient() for env-driven resolution.`,
118
135
  "RK_DEVICE_IP_UNRESOLVED"
119
136
  );
120
137
  }
121
- this.deviceIp = opts.deviceIp;
138
+ this.deviceIp = opts.deviceIp ?? opts.driver!.label;
122
139
  this.keyDelay = opts.keyDelay ?? DEFAULT_KEY_DELAY;
123
140
  this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
124
141
  this.serverUrl = resolveServerUrl(opts.serverUrl);
125
142
 
126
- this.ecp = new EcpClient(this.deviceIp, this.requestTimeoutMs);
143
+ this.driverTarget = opts.driver?.initialTarget;
144
+ this.ecp = new EcpClient(this.driverTarget ?? this.deviceIp, this.requestTimeoutMs);
127
145
  this.queue = new EcpRequestQueue({ ...ECP_INTERVAL_MS, ...opts.intervals });
128
146
  // Construction is free (no I/O); first `ensureStarted()` POSTs
129
147
  // `/logs/start` and opens the SSE tail.
130
148
  this.logSession =
131
149
  opts.logSession ??
150
+ (opts.driver ? driverLogSession(opts.driver) : undefined) ??
132
151
  new LogSession({
133
152
  serverUrl: this.serverUrl,
134
153
  device: this.deviceIp,
@@ -136,6 +155,11 @@ export class RkClient {
136
155
  });
137
156
  }
138
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
+
139
163
  /** Accessor for the active `rk server` URL (for setup helpers). */
140
164
  getServerUrl(): string {
141
165
  return this.serverUrl;
@@ -313,11 +337,26 @@ export class RkClient {
313
337
  return parseActiveApp(root);
314
338
  }
315
339
 
316
- /** PNG screenshot bytes; rate-limited at 500ms between captures. */
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
+ */
317
345
  async captureScreenshot(): Promise<Buffer> {
346
+ this.assertScreenshotSupported();
318
347
  return this.runEcp("capture", () => this.ecp.captureScreen("png"));
319
348
  }
320
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
+
321
360
  /**
322
361
  * Validate and send one keypress. Invalid keys throw before hitting
323
362
  * the queue so a typo in a test doesn't consume a slot.
@@ -329,6 +368,21 @@ export class RkClient {
329
368
  });
330
369
  }
331
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
+
332
386
  /**
333
387
  * Send a sequence of keypresses with an inter-key delay (between end
334
388
  * of one and start of next). All keys validated up-front so a typo
@@ -377,18 +431,29 @@ export class RkClient {
377
431
  * block on the readiness + dialog gate. HTTP errors propagate verbatim
378
432
  * unless the auto-launch fallback below absorbs them; readiness/dialog
379
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.
380
438
  */
381
439
  private async launchAndAwaitReady(
382
440
  channelId: string,
383
441
  deepLink: Record<string, string> | undefined,
384
442
  timeoutMs: number,
385
- readyNode?: NodePredicate
443
+ readyNode?: NodePredicate,
444
+ logSince: LogPosition = this.logPosition()
386
445
  ): Promise<void> {
387
- const url = buildLaunchUrl(this.deviceIp, channelId, deepLink);
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
+ };
388
455
  try {
389
- await this.runEcp("launch", async () => {
390
- await postNoBody(url, this.requestTimeoutMs);
391
- });
456
+ await this.runEcp("launch", () => this.postLaunch(channelId, deepLink));
392
457
  } catch (err) {
393
458
  // Newer firmware auto-launches sideloaded `dev` builds after
394
459
  // `replace()`, so the POST races a launch that is already
@@ -396,12 +461,12 @@ export class RkClient {
396
461
  // waiting on the auto-launched app instead of treating the
397
462
  // POST verdict as fatal.
398
463
  if (channelId === "dev" && deepLink === undefined && isAutoLaunchTolerableError(err)) {
399
- await this.awaitReadyAndCheckDialog({ channelId, timeoutMs, readyNode });
464
+ await this.awaitReadyAndCheckDialog(gate);
400
465
  return;
401
466
  }
402
467
  throw err;
403
468
  }
404
- await this.awaitReadyAndCheckDialog({ channelId, timeoutMs, readyNode });
469
+ await this.awaitReadyAndCheckDialog(gate);
405
470
  }
406
471
 
407
472
  // -----------------------------------------------------------------
@@ -430,6 +495,62 @@ export class RkClient {
430
495
  return RkClient.detectSystemDialog(tree);
431
496
  }
432
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
+
433
554
  /**
434
555
  * Single-shot crash probe used by polling loops between ticks.
435
556
  * Throws `AppCrashedError` if active-app is a home surface — see
@@ -528,6 +649,11 @@ export class RkClient {
528
649
  let lastStatus = "unknown";
529
650
  let lastErrorMessage: string | undefined;
530
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
+
531
657
  do {
532
658
  attempts += 1;
533
659
  const result = await tcpProbe(this.deviceIp, ROKU_INSTALLER_PORT, INSTALLER_PORT_TCP_TIMEOUT_MS);
@@ -563,6 +689,10 @@ export class RkClient {
563
689
  * fallback) passes on the same iteration — avoids one-tick races where
564
690
  * the app is launching but the scene has not rendered.
565
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
+ *
566
696
  * Stability: requires `READINESS_CONFIRM_POLLS` consecutive matches
567
697
  * before declaring ready, catching the "crash shortly after splash"
568
698
  * pattern. Home-screen responses are tolerated while we have not
@@ -571,11 +701,19 @@ export class RkClient {
571
701
  * the full readiness budget expires. Once we HAVE seen app-owned
572
702
  * evidence, a persistent post-grace Home response is classified as
573
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.
574
710
  */
575
711
  private async awaitReadyAndCheckDialog(opts: {
576
712
  channelId: string;
577
713
  timeoutMs: number;
578
714
  readyNode?: NodePredicate;
715
+ relaunch: () => Promise<void>;
716
+ logSince: LogPosition;
579
717
  }): Promise<void> {
580
718
  const startedAt = Date.now();
581
719
  const deadline = startedAt + opts.timeoutMs;
@@ -611,6 +749,21 @@ export class RkClient {
611
749
  // explicitly identified the target channel. Once this is true,
612
750
  // a later Home report is a crash signal rather than active-app lag.
613
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
+ };
614
767
 
615
768
  const treeContainsChannel = (node: AppUiNode, channelId: string): boolean => {
616
769
  if (node.tag === "plugin" && node.attrs?.id === channelId) {
@@ -619,6 +772,18 @@ export class RkClient {
619
772
  return node.children.some(child => treeContainsChannel(child, channelId));
620
773
  };
621
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
+
622
787
  while (Date.now() < deadline) {
623
788
  const active = await this.queryActiveApp();
624
789
  lastActiveApp = active;
@@ -637,7 +802,7 @@ export class RkClient {
637
802
  // tree as a stronger signal than active-app.
638
803
  channelVisibleInTree = treeContainsChannel(tree, opts.channelId);
639
804
  if (idMatches || channelVisibleInTree) {
640
- hadChannelEvidence = true;
805
+ noteChannelEvidence();
641
806
  hadActiveChannelEvidence = true;
642
807
  }
643
808
  } catch (err) {
@@ -665,7 +830,12 @@ export class RkClient {
665
830
  opts.readyNode && isHomeScreenActiveApp(active) && predicateMatches && !hadActiveChannelEvidence
666
831
  );
667
832
  if (tree && (idMatches || channelVisibleInTree || readyNodeVisibleWhileHome)) {
668
- if (predicateMatches) {
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)) {
669
839
  // Dialog check on every match: a dialog appearing AFTER
670
840
  // first-render must still fail the deploy.
671
841
  const dialog = RkClient.detectSystemDialog(tree);
@@ -684,7 +854,7 @@ export class RkClient {
684
854
  }
685
855
  consecutiveMatches += 1;
686
856
  hadAnyMatch = true;
687
- hadChannelEvidence = true;
857
+ noteChannelEvidence();
688
858
  if (consecutiveMatches >= READINESS_CONFIRM_POLLS) {
689
859
  return;
690
860
  }
@@ -718,6 +888,21 @@ export class RkClient {
718
888
  lastActiveApp: active
719
889
  });
720
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
+ }
721
906
  if (hadChannelEvidence && opts.readyNode) {
722
907
  // The app launched but never satisfied the
723
908
  // caller's readiness predicate. Do not relabel
@@ -743,14 +928,43 @@ export class RkClient {
743
928
  }
744
929
 
745
930
  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
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
752
950
  // instead of retrying every remaining suite against a dead VM.
753
- markDeviceFatal(`deploy: app never left home screen after launch`);
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
+ }
754
968
  throw new DeployFailedError(`Deploy failed: ${reason}`, {
755
969
  deviceIp: this.deviceIp,
756
970
  expectedChannelId: opts.channelId,
@@ -852,6 +1066,12 @@ export class RkClient {
852
1066
  * could be partial/concatenated at the reconnect boundary).
853
1067
  * `expectNoLog` is the asymmetric strict counterpart: absence is
854
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`.
855
1075
  */
856
1076
  async waitForLog(pattern: RegExp, opts: LogQueryOptions): Promise<LogLine> {
857
1077
  await this.ensureLogSession();
@@ -878,14 +1098,23 @@ export class RkClient {
878
1098
 
879
1099
  return new Promise<LogLine>((resolve, reject) => {
880
1100
  let settled = false;
1101
+ let timer: ReturnType<typeof setTimeout> | undefined;
1102
+ let resyncTried = false;
881
1103
  const recent: LogLine[] = [];
882
1104
  const settle = (fn: () => void): void => {
883
1105
  if (settled) return;
884
1106
  settled = true;
885
1107
  unsubscribe();
886
- clearTimeout(timer);
1108
+ if (timer !== undefined) clearTimeout(timer);
887
1109
  fn();
888
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
+ };
889
1118
  const unsubscribe = buffer.subscribe(entry => {
890
1119
  if (settled) return;
891
1120
  linesChecked += 1;
@@ -901,7 +1130,8 @@ export class RkClient {
901
1130
  settle(() => resolve(entry.line));
902
1131
  }
903
1132
  });
904
- const timer = setTimeout(() => {
1133
+ const onDeadline = (): void => {
1134
+ if (settled) return;
905
1135
  // A gap inside the window makes a non-result ambiguous;
906
1136
  // surface the gap rather than a bare timeout.
907
1137
  const gap = buffer.hasGapWithin(since);
@@ -916,10 +1146,37 @@ export class RkClient {
916
1146
  );
917
1147
  return;
918
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
+ }
919
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
+ : "";
920
1177
  const msg =
921
1178
  `waitForLog timed out after ${timeoutMs}ms waiting for ${pattern} ` +
922
- `(since=${since}, linesChecked=${linesChecked})${preview ? `\nRecent lines:\n${preview}` : ""}`;
1179
+ `(since=${since}, linesChecked=${linesChecked}${silent})${preview ? `\nRecent lines:\n${preview}` : ""}`;
923
1180
  settle(() =>
924
1181
  reject(
925
1182
  new WaitForLogTimeoutError(msg, {
@@ -931,11 +1188,8 @@ export class RkClient {
931
1188
  })
932
1189
  )
933
1190
  );
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
- }
1191
+ };
1192
+ arm(timeoutMs, onDeadline);
939
1193
  });
940
1194
  }
941
1195
 
@@ -1077,6 +1331,11 @@ export class RkClient {
1077
1331
  );
1078
1332
  }
1079
1333
 
1334
+ if (this.driver) {
1335
+ await this.deployThroughDriver(absPath, opts);
1336
+ return;
1337
+ }
1338
+
1080
1339
  // The build runs server-side via `requestServerBuild`, so we do
1081
1340
  // not need esbuild loaded in this process — keeps ~200 MiB of
1082
1341
  // orphan `esbuild --service` procs out of the build container.
@@ -1088,7 +1347,7 @@ export class RkClient {
1088
1347
 
1089
1348
  // Per-integration-test out-dir keeps artifacts out of the app's
1090
1349
  // own `dist-build` so local `rk build` runs don't collide.
1091
- const outDir = "dist-integration-test";
1350
+ const outDir = INTEGRATION_BUILD_OUT_DIR;
1092
1351
  let pkgDir: string;
1093
1352
  // The build is memoised per-process via `getOrCreateBuildPromise`:
1094
1353
  // when the same `(absPath, outDir, buildType, dropConsole)` tuple
@@ -1160,6 +1419,7 @@ export class RkClient {
1160
1419
  const { ip, password } = await this.resolveTargetDevice(opts?.deviceSelector, absPath, tooling);
1161
1420
 
1162
1421
  const { appType, libraryName } = await readAppTypeFromManifest(pkgDir, tooling.readManifestEntries);
1422
+ const launchLogSince = this.logPosition();
1163
1423
 
1164
1424
  try {
1165
1425
  const device = new tooling.RokuDevice(ip, password, await tooling.getTempDir());
@@ -1222,7 +1482,13 @@ export class RkClient {
1222
1482
  const channelId = opts?.channelId ?? "dev";
1223
1483
  const readinessTimeout = opts?.readinessTimeout ?? READINESS_TIMEOUT_MS;
1224
1484
  try {
1225
- await this.launchAndAwaitReady(channelId, opts?.deepLink, readinessTimeout, opts?.readyNode);
1485
+ await this.launchAndAwaitReady(
1486
+ channelId,
1487
+ opts?.deepLink,
1488
+ readinessTimeout,
1489
+ opts?.readyNode,
1490
+ launchLogSince
1491
+ );
1226
1492
  } catch (err) {
1227
1493
  if (err instanceof DeployFailedError) {
1228
1494
  invalidateBuildCacheForApp(absPath);
@@ -1245,6 +1511,26 @@ export class RkClient {
1245
1511
  }
1246
1512
  }
1247
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
+
1248
1534
  /**
1249
1535
  * Cold sideload + reliable deep-link delivery.
1250
1536
  *
@@ -1329,11 +1615,8 @@ export class RkClient {
1329
1615
  // the authoritative success signal.
1330
1616
  const ackPos = this.logPosition();
1331
1617
  const channelId = opts.channelId ?? "dev";
1332
- const launchUrl = buildLaunchUrl(this.deviceIp, channelId, deepLink);
1333
1618
  try {
1334
- await this.runEcp("launch", async () => {
1335
- await postNoBody(launchUrl, this.requestTimeoutMs);
1336
- });
1619
+ await this.runEcp("launch", () => this.postLaunch(channelId, deepLink));
1337
1620
  } catch (err) {
1338
1621
  // The params-ack log — not the POST status — is the
1339
1622
  // authoritative signal here, so a POST that raced the
@@ -1458,6 +1741,7 @@ export class RkClient {
1458
1741
  * `lastPng` for artifacts (excluded from `toJSON`).
1459
1742
  */
1460
1743
  async captureStableScreenshot(opts: CaptureStableOptions): Promise<Buffer> {
1744
+ this.assertScreenshotSupported();
1461
1745
  const interval = opts.interval ?? SCREENSHOT_STABLE_DEFAULT_INTERVAL_MS;
1462
1746
  const timeoutMs = opts.timeout;
1463
1747
  const deadline = Date.now() + timeoutMs;
@@ -1537,6 +1821,21 @@ export class RkClient {
1537
1821
  * Retry happens inside the queue slot so the backoff does not let
1538
1822
  * another request slip between the two attempts.
1539
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
+
1540
1839
  private runEcp<T>(category: EcpCategory, op: () => Promise<T>): Promise<T> {
1541
1840
  return this.queue.enqueue(category, () =>
1542
1841
  retryOnTransient(op, {
@@ -1547,6 +1846,15 @@ export class RkClient {
1547
1846
  }
1548
1847
  }
1549
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
+
1550
1858
  function errorMessage(err: unknown): string {
1551
1859
  if (err instanceof Error) return err.message;
1552
1860
  try {