@roku-sdk/device-test-client 0.0.0-stage → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BREAKING_CHANGES.md +17 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/baselines.md +221 -0
- package/docs/configuration.md +258 -0
- package/docs/dom-assertions.md +264 -0
- package/docs/errors.md +544 -0
- package/docs/examples/deep-link-cold-start.md +85 -0
- package/docs/examples/focus-navigation.md +81 -0
- package/docs/examples/log-driven-assertion.md +99 -0
- package/docs/examples/precondition-skips.md +150 -0
- package/docs/examples/screenshot-regression.md +103 -0
- package/docs/examples/smoke-test.md +69 -0
- package/docs/getting-started.md +195 -0
- package/docs/jest-integration.md +331 -0
- package/docs/log-assertions.md +349 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +657 -0
- package/docs/screenshot-assertions.md +316 -0
- package/package.json +81 -3
- package/src/assertions/assertions.ts +661 -0
- package/src/assertions/baseline-env-guard.ts +43 -0
- package/src/assertions/baselines.ts +178 -0
- package/src/assertions/screenshot-artifacts.ts +39 -0
- package/src/assertions/screenshot-diff.ts +447 -0
- package/src/client/build-cache.ts +292 -0
- package/src/client/client.ts +1964 -0
- package/src/client/deploy.ts +407 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +170 -0
- package/src/client/health.ts +345 -0
- package/src/client/parsers.ts +162 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +520 -0
- package/src/device/device-class.ts +57 -0
- package/src/device/device-env-sync.ts +58 -0
- package/src/device/device-resolver-sync.ts +43 -0
- package/src/device/device-resolver.ts +141 -0
- package/src/device/lease-provisioning.ts +54 -0
- package/src/errors.ts +774 -0
- package/src/index.ts +181 -0
- package/src/internal/constants.ts +3 -0
- package/src/jest/artifacts.ts +87 -0
- package/src/jest/device-test-environment.ts +420 -0
- package/src/jest/failure-artifacts.ts +169 -0
- package/src/jest/global-setup.ts +137 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +490 -0
- package/src/jest/jest-setup.ts +53 -0
- package/src/jest/preconditions.ts +239 -0
- package/src/jest/telemetry.ts +331 -0
- package/src/logs/device-log-sink.ts +69 -0
- package/src/logs/log-buffer.ts +278 -0
- package/src/logs/log-session.ts +436 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +472 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- package/support/paths.ts +13 -0
|
@@ -0,0 +1,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
|
+
}
|