@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.
- package/BREAKING_CHANGES.md +17 -0
- package/docs/errors.md +25 -6
- package/docs/log-assertions.md +35 -0
- package/docs/rkclient.md +3 -1
- package/package.json +16 -15
- package/src/client/build-cache.ts +4 -4
- package/src/client/client.ts +346 -38
- package/src/client/deploy.ts +7 -2
- package/src/client/factory.ts +25 -2
- package/src/client/health.ts +131 -0
- package/src/client/parsers.ts +10 -3
- package/src/constants.ts +49 -0
- package/src/errors.ts +2 -1
- package/src/index.ts +18 -23
- package/src/jest/global-setup.ts +4 -0
- package/src/jest/install-hooks.ts +3 -1
- package/src/logs/log-buffer.ts +17 -4
- package/src/logs/log-session.ts +62 -0
- package/src/types.ts +29 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- package/support/paths.ts +13 -0
- package/internal/constants.ts +0 -54
- package/internal/deploy.ts +0 -60
- package/internal/paths.ts +0 -15
package/src/client/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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",
|
|
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(
|
|
464
|
+
await this.awaitReadyAndCheckDialog(gate);
|
|
400
465
|
return;
|
|
401
466
|
}
|
|
402
467
|
throw err;
|
|
403
468
|
}
|
|
404
|
-
await this.awaitReadyAndCheckDialog(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
935
|
-
|
|
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 =
|
|
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(
|
|
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",
|
|
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 {
|