@roku-sdk/device-test-client 2.18.0 → 2.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BREAKING_CHANGES.md +17 -0
- package/docs/errors.md +25 -6
- package/docs/log-assertions.md +35 -0
- package/docs/rkclient.md +3 -1
- package/package.json +16 -15
- package/src/client/build-cache.ts +4 -4
- package/src/client/client.ts +346 -38
- package/src/client/deploy.ts +7 -2
- package/src/client/factory.ts +25 -2
- package/src/client/health.ts +131 -0
- package/src/client/parsers.ts +10 -3
- package/src/constants.ts +49 -0
- package/src/errors.ts +2 -1
- package/src/index.ts +18 -23
- package/src/jest/global-setup.ts +4 -0
- package/src/jest/install-hooks.ts +3 -1
- package/src/logs/log-buffer.ts +17 -4
- package/src/logs/log-session.ts +62 -0
- package/src/types.ts +29 -0
- package/support/constants.ts +17 -0
- package/support/deploy.ts +27 -0
- package/support/paths.ts +13 -0
- package/internal/constants.ts +0 -54
- package/internal/deploy.ts +0 -60
- package/internal/paths.ts +0 -15
package/src/client/deploy.ts
CHANGED
|
@@ -265,14 +265,19 @@ export async function requestServerBuild(serverUrl: string, payload: ServerBuild
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
/** Path and query of an ECP launch request, independent of any host. */
|
|
269
|
+
export function buildLaunchPath(channelId: string, deepLink?: Record<string, string>): string {
|
|
270
|
+
const base = `/launch/${encodeURIComponent(channelId)}`;
|
|
270
271
|
if (!deepLink || Object.keys(deepLink).length === 0) return base;
|
|
271
272
|
const params = new URLSearchParams();
|
|
272
273
|
for (const [k, v] of Object.entries(deepLink)) params.append(k, v);
|
|
273
274
|
return `${base}?${params.toString()}`;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
export function buildLaunchUrl(ip: string, channelId: string, deepLink?: Record<string, string>): string {
|
|
278
|
+
return `http://${ip}:${ECP_PORT}${buildLaunchPath(channelId, deepLink)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
276
281
|
/**
|
|
277
282
|
* POST a no-body ECP request with the caller's timeout. Throws
|
|
278
283
|
* `ToolingError` with the standard tooling code taxonomy so retry
|
package/src/client/factory.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { ARTIFACT_FILE, DEFAULT_KEY_DELAY, ENV_VAR } from "../constants";
|
|
21
21
|
import { resolveDeviceIpSync } from "../device/device-resolver-sync";
|
|
22
22
|
import { defaultTestResultsDir } from "../paths";
|
|
23
|
-
import type { DeviceInfo, RkClientOptions } from "../types";
|
|
23
|
+
import type { DeploymentDriver, DeviceInfo, RkClientOptions } from "../types";
|
|
24
24
|
import { RkClient } from "./client";
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -30,9 +30,11 @@ import { RkClient } from "./client";
|
|
|
30
30
|
*/
|
|
31
31
|
export function createDefaultClient(opts: RkClientOptions = {}): RkClient {
|
|
32
32
|
const resultsDir = defaultTestResultsDir();
|
|
33
|
-
const
|
|
33
|
+
const driver = opts.driver ?? loadDriverFromEnv();
|
|
34
|
+
const deviceIp = driver ? opts.deviceIp : (opts.deviceIp ?? resolveDeviceIpSync(resultsDir));
|
|
34
35
|
const client = new RkClient({
|
|
35
36
|
...opts,
|
|
37
|
+
driver,
|
|
36
38
|
deviceIp,
|
|
37
39
|
keyDelay: opts.keyDelay ?? envKeyDelay()
|
|
38
40
|
});
|
|
@@ -84,6 +86,27 @@ export function createDefaultClient(opts: RkClientOptions = {}): RkClient {
|
|
|
84
86
|
return client;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `RK_DEVICE_DRIVER_MODULE` names a module providing the deployment driver
|
|
91
|
+
* (see `DeploymentDriver`). Loaded synchronously: client construction is sync.
|
|
92
|
+
*/
|
|
93
|
+
function loadDriverFromEnv(): DeploymentDriver | undefined {
|
|
94
|
+
const modulePath = process.env[ENV_VAR.RK_DEVICE_DRIVER_MODULE];
|
|
95
|
+
if (!modulePath) return undefined;
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
97
|
+
const mod = require(modulePath) as {
|
|
98
|
+
createDeploymentDriver?: () => DeploymentDriver;
|
|
99
|
+
default?: () => DeploymentDriver;
|
|
100
|
+
};
|
|
101
|
+
const factory = mod.createDeploymentDriver ?? mod.default;
|
|
102
|
+
if (typeof factory !== "function") {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${ENV_VAR.RK_DEVICE_DRIVER_MODULE}=${modulePath} must export createDeploymentDriver() (or a default factory).`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return factory();
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function envKeyDelay(): number {
|
|
88
111
|
const raw = process.env[ENV_VAR.KEY_DELAY];
|
|
89
112
|
const n = Number(raw);
|
package/src/client/health.ts
CHANGED
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
* budget × every remaining suite (the ~20-minute waste this latch
|
|
25
25
|
* exists to eliminate).
|
|
26
26
|
*
|
|
27
|
+
* Because the latch is this expensive to get wrong — it fails the
|
|
28
|
+
* whole shard and recycles the lease — it requires the device to
|
|
29
|
+
* have no record of ever foregrounding a channel, and the failing
|
|
30
|
+
* deploy's device log to show no launch of its own. A deploy that saw
|
|
31
|
+
* one on screen calls `markChannelForegrounded`, and from then on a
|
|
32
|
+
* stalled launch on that VM is reported as the soft, recoverable
|
|
33
|
+
* fault it is indistinguishable from. A deploy whose log shows its
|
|
34
|
+
* channel entering is soft for the same reason: the VM can start
|
|
35
|
+
* one. Both stay soft only until `noteForegroundFailure`'s streak
|
|
36
|
+
* reaches its limit.
|
|
37
|
+
*
|
|
27
38
|
* Recycling is the CELL's job, not this process's. When
|
|
28
39
|
* `RK_DEVICE_FATAL_SENTINEL` names a path, `markDeviceFatal` drops
|
|
29
40
|
* a one-line JSON marker there the instant the latch flips. The CI
|
|
@@ -70,6 +81,9 @@ let deviceHealthy = true;
|
|
|
70
81
|
let unhealthyReason: string | null = null;
|
|
71
82
|
let deviceFatal = false;
|
|
72
83
|
let fatalReason: string | null = null;
|
|
84
|
+
let foregroundedDeviceIp: string | null = null;
|
|
85
|
+
/** Deploys in a row that failed to foreground the channel; reset by any success. */
|
|
86
|
+
let foregroundFailureStreak = 0;
|
|
73
87
|
|
|
74
88
|
export function isDeviceHealthy(): boolean {
|
|
75
89
|
return deviceHealthy;
|
|
@@ -91,6 +105,59 @@ export function getUnhealthyReason(): string | null {
|
|
|
91
105
|
return unhealthyReason;
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Record that `deviceIp` foregrounded a sideloaded channel — a deploy
|
|
110
|
+
* saw one on screen, so the VM is structurally capable of launching
|
|
111
|
+
* one. Keyed by IP because that is the granularity the fatal
|
|
112
|
+
* latch claims ("unrecoverable on THIS VM"): a re-leased slot is a
|
|
113
|
+
* different instance and starts with no history.
|
|
114
|
+
*
|
|
115
|
+
* Persisted beside the recycle sentinel for the same reason it exists —
|
|
116
|
+
* Jest's per-file module registry reset (see doc-block) would otherwise
|
|
117
|
+
* drop the evidence between test files, which is exactly where the
|
|
118
|
+
* misdiagnosis it prevents occurs: one file foregrounds the app, the
|
|
119
|
+
* next file's deploy stalls and, remembering nothing, declares the VM
|
|
120
|
+
* incapable of what the previous file just watched it do.
|
|
121
|
+
*/
|
|
122
|
+
export function markChannelForegrounded(deviceIp: string): void {
|
|
123
|
+
if (foregroundedDeviceIp === deviceIp && foregroundFailureStreak === 0) return;
|
|
124
|
+
foregroundedDeviceIp = deviceIp;
|
|
125
|
+
// A channel on screen ends whatever streak was building: the next
|
|
126
|
+
// failure starts counting from a VM that just demonstrably worked.
|
|
127
|
+
foregroundFailureStreak = 0;
|
|
128
|
+
writeLaunchEvidence(deviceIp);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record a deploy that never got the channel on screen, and report how
|
|
133
|
+
* many have now failed in a row on `deviceIp`.
|
|
134
|
+
*
|
|
135
|
+
* Launch history alone cannot separate a bad launch from a VM that
|
|
136
|
+
* worked and then died, because both look identical from inside the
|
|
137
|
+
* failing deploy. The streak separates them over time: a transient
|
|
138
|
+
* stall is followed by a channel on screen, which resets it, while a
|
|
139
|
+
* dead VM only ever adds to it. Persisted beside the launch evidence
|
|
140
|
+
* and for the same reason — Jest's per-file registry reset would
|
|
141
|
+
* otherwise restart the count in every file, which is precisely the
|
|
142
|
+
* span over which a wedge has to be recognised.
|
|
143
|
+
*/
|
|
144
|
+
export function noteForegroundFailure(deviceIp: string): number {
|
|
145
|
+
if (foregroundedDeviceIp !== deviceIp) {
|
|
146
|
+
hydrateLaunchEvidenceFor(deviceIp);
|
|
147
|
+
}
|
|
148
|
+
foregroundFailureStreak += 1;
|
|
149
|
+
writeLaunchEvidence(deviceIp);
|
|
150
|
+
return foregroundFailureStreak;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `true` once `deviceIp` has been seen foregrounding a sideloaded channel (this process or a prior test file). */
|
|
154
|
+
export function hasForegroundedChannel(deviceIp: string): boolean {
|
|
155
|
+
if (foregroundedDeviceIp !== deviceIp) {
|
|
156
|
+
hydrateLaunchEvidenceFor(deviceIp);
|
|
157
|
+
}
|
|
158
|
+
return foregroundedDeviceIp === deviceIp;
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/**
|
|
95
162
|
* Latch the device as FATALLY wedged: reachable on the network but
|
|
96
163
|
* structurally unable to foreground a sideloaded channel on this VM.
|
|
@@ -145,6 +212,68 @@ function writeFatalSentinel(reason: string): void {
|
|
|
145
212
|
}
|
|
146
213
|
}
|
|
147
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Path of the launch-evidence marker: a sibling of the recycle sentinel,
|
|
217
|
+
* so one env var configures the whole wedge-classification state and the
|
|
218
|
+
* two files share a lifetime. `undefined` (env unset) keeps local runs
|
|
219
|
+
* and unit tests off the filesystem, at the cost of evidence that does
|
|
220
|
+
* not cross test files — the same opt-in trade the sentinel makes.
|
|
221
|
+
*
|
|
222
|
+
* The marker records the IP it was written for, so a file left behind by
|
|
223
|
+
* an earlier job on a reused workspace is inert: a later lease answers on
|
|
224
|
+
* a different IP and the evidence simply does not match.
|
|
225
|
+
*/
|
|
226
|
+
function launchEvidencePath(): string | undefined {
|
|
227
|
+
const sentinelPath = process.env.RK_DEVICE_FATAL_SENTINEL;
|
|
228
|
+
return sentinelPath ? `${sentinelPath}.launched` : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Best-effort, like `writeFatalSentinel`: a lost marker degrades to today's stricter verdict, never to a missed wedge. */
|
|
232
|
+
function writeLaunchEvidence(deviceIp: string): void {
|
|
233
|
+
const path = launchEvidencePath();
|
|
234
|
+
if (!path) return;
|
|
235
|
+
try {
|
|
236
|
+
const record = {
|
|
237
|
+
deviceIp,
|
|
238
|
+
ts: new Date().toISOString(),
|
|
239
|
+
foregrounded: foregroundedDeviceIp === deviceIp,
|
|
240
|
+
failures: foregroundFailureStreak
|
|
241
|
+
};
|
|
242
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
243
|
+
} catch {
|
|
244
|
+
// best-effort — see doc-block.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Adopt the on-disk launch evidence when it was recorded for `deviceIp`. */
|
|
249
|
+
function hydrateLaunchEvidenceFor(deviceIp: string): void {
|
|
250
|
+
const path = launchEvidencePath();
|
|
251
|
+
if (!path || !existsSync(path)) return;
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as {
|
|
254
|
+
deviceIp?: unknown;
|
|
255
|
+
foregrounded?: unknown;
|
|
256
|
+
failures?: unknown;
|
|
257
|
+
};
|
|
258
|
+
if (parsed.deviceIp === deviceIp) {
|
|
259
|
+
// A streak can be recorded for a device that has never put a
|
|
260
|
+
// channel on screen; only an explicit `false` says so.
|
|
261
|
+
if (parsed.foregrounded !== false) {
|
|
262
|
+
foregroundedDeviceIp = deviceIp;
|
|
263
|
+
}
|
|
264
|
+
// A marker written before the streak existed, or with a
|
|
265
|
+
// junk count, reads as no failures — the stricter verdict
|
|
266
|
+
// is the fatal one, so an unreadable count must not latch it.
|
|
267
|
+
foregroundFailureStreak =
|
|
268
|
+
typeof parsed.failures === "number" && Number.isFinite(parsed.failures) && parsed.failures > 0
|
|
269
|
+
? Math.floor(parsed.failures)
|
|
270
|
+
: 0;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Unreadable evidence proves nothing; leave the stricter verdict in place.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
148
277
|
/**
|
|
149
278
|
* Rehydrate the in-memory fatal latch from the on-disk sentinel.
|
|
150
279
|
*
|
|
@@ -211,4 +340,6 @@ export function __resetDeviceHealthForTests(): void {
|
|
|
211
340
|
unhealthyReason = null;
|
|
212
341
|
deviceFatal = false;
|
|
213
342
|
fatalReason = null;
|
|
343
|
+
foregroundedDeviceIp = null;
|
|
344
|
+
foregroundFailureStreak = 0;
|
|
214
345
|
}
|
package/src/client/parsers.ts
CHANGED
|
@@ -44,14 +44,21 @@ export function tupleToAppUiNode(tuple: NodeTuple): AppUiNode {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const BOUNDS_NUMBER = String.raw`(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)`;
|
|
48
|
+
const BOUNDS_PATTERN = new RegExp(
|
|
49
|
+
String.raw`\{\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*,\s*${BOUNDS_NUMBER}\s*\}`
|
|
50
|
+
);
|
|
51
|
+
|
|
47
52
|
/**
|
|
48
|
-
* Parse a SceneGraph bounds string `"{x, y, w, h}"`.
|
|
49
|
-
*
|
|
53
|
+
* Parse a SceneGraph bounds string `"{x, y, w, h}"`. Values are
|
|
54
|
+
* returned unrounded: a 720p device reports FHD-canvas bounds in
|
|
55
|
+
* float32, e.g. `"{250, 899.99994, 10, 10}"`. Returns a zero rect on
|
|
56
|
+
* parse failure so callers don't branch on `null`.
|
|
50
57
|
*/
|
|
51
58
|
export function parseBounds(raw: string | undefined): AppUiNode["bounds"] {
|
|
52
59
|
const zero = { x: 0, y: 0, width: 0, height: 0 };
|
|
53
60
|
if (!raw) return zero;
|
|
54
|
-
const match =
|
|
61
|
+
const match = BOUNDS_PATTERN.exec(raw);
|
|
55
62
|
if (!match) return zero;
|
|
56
63
|
return {
|
|
57
64
|
x: Number(match[1]),
|
package/src/constants.ts
CHANGED
|
@@ -14,6 +14,12 @@ export const ENV_VAR = {
|
|
|
14
14
|
* `.roku/` active-device resolution. Must be a local-network IPv4.
|
|
15
15
|
*/
|
|
16
16
|
RK_DEVICE_IP: "RK_DEVICE_IP",
|
|
17
|
+
/**
|
|
18
|
+
* Path to a module that default-exports (or exports `createDeploymentDriver`)
|
|
19
|
+
* a factory returning a `DeploymentDriver`. When set, `createDefaultClient`
|
|
20
|
+
* runs apps through the driver instead of a device and needs no device IP.
|
|
21
|
+
*/
|
|
22
|
+
RK_DEVICE_DRIVER_MODULE: "RK_DEVICE_DRIVER_MODULE",
|
|
17
23
|
/**
|
|
18
24
|
* Roku developer-mode password. Set together with `RK_DEVICE_IP`
|
|
19
25
|
* to bypass `.roku/roku-dev-config.json`. A partial pair is a
|
|
@@ -207,6 +213,19 @@ export const INSTALLER_PORT_TCP_TIMEOUT_MS = 1_500;
|
|
|
207
213
|
*/
|
|
208
214
|
export const DEVICE_RECOVERY_DEFAULT_RETRIES = 3;
|
|
209
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Consecutive deploys that may fail to foreground the channel on a VM
|
|
218
|
+
* known to have foregrounded one earlier before the wedge is treated as
|
|
219
|
+
* fatal rather than as a bad launch.
|
|
220
|
+
*
|
|
221
|
+
* One file's full deploy-retry budget is allowed to fail softly, so a
|
|
222
|
+
* genuinely transient stall still resolves the way it always has; the
|
|
223
|
+
* next failure after that is a second file reaching the same verdict,
|
|
224
|
+
* which no amount of further retrying has ever cured. Derived from the
|
|
225
|
+
* retry budget so the two cannot drift apart.
|
|
226
|
+
*/
|
|
227
|
+
export const FOREGROUND_FAILURE_STREAK_FATAL = DEVICE_RECOVERY_DEFAULT_RETRIES + 1;
|
|
228
|
+
|
|
210
229
|
/**
|
|
211
230
|
* Default post-sideload readiness deadline. Covers cold-start on the
|
|
212
231
|
* slowest reference hardware (Express ~3s, Ultra ~1s) with headroom
|
|
@@ -247,6 +266,24 @@ export const HOME_SCREEN_GRACE_FRACTION = 0.25;
|
|
|
247
266
|
*/
|
|
248
267
|
export const HOME_SCREEN_GRACE_MIN_MS = 500;
|
|
249
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Launch requests the readiness gate re-sends, per deploy, when the
|
|
271
|
+
* device log shows the channel entering and then exiting with
|
|
272
|
+
* `EXIT_APP_ERROR`. Firmware 15.3.4 on virtualized hosts ends some
|
|
273
|
+
* launches that way right after a sideload, its TypeScript process still
|
|
274
|
+
* compiling the build `replace()` removed, and a launch of the same build
|
|
275
|
+
* a few seconds later succeeds. A build that crashes during mount exits
|
|
276
|
+
* the same way and pays at most this many extra launches per deploy.
|
|
277
|
+
*/
|
|
278
|
+
export const LAUNCH_CRASH_RELAUNCH_MAX = 2;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Minimum spacing between two launch requests from the readiness gate.
|
|
282
|
+
* A relaunch also waits out the home-screen grace window, so InstantOn's
|
|
283
|
+
* own exit-and-relaunch is not raced.
|
|
284
|
+
*/
|
|
285
|
+
export const LAUNCH_CRASH_RELAUNCH_GAP_MS = 2_000;
|
|
286
|
+
|
|
250
287
|
/**
|
|
251
288
|
* Tag names of root-level system dialogs (e.g. "Display type detection").
|
|
252
289
|
* Matching on tag at the root rules out app-authored `Dialog` subclasses
|
|
@@ -282,6 +319,9 @@ export const BASELINE_RESOLUTION_DIR = {
|
|
|
282
319
|
fhd: "fhd"
|
|
283
320
|
} as const;
|
|
284
321
|
|
|
322
|
+
/** Build output directory the client uses for fixture apps, relative to the app root. */
|
|
323
|
+
export const INTEGRATION_BUILD_OUT_DIR = "dist-integration-test";
|
|
324
|
+
|
|
285
325
|
/** Filenames written under each consumer's `test-results/` directory. */
|
|
286
326
|
export const ARTIFACT_FILE = {
|
|
287
327
|
DEVICE_ENV: "device-env.json",
|
|
@@ -417,6 +457,15 @@ export const WAIT_FOR_LOG_RECENT_CHARS_MAX = 600;
|
|
|
417
457
|
*/
|
|
418
458
|
export const SSE_PENDING_MAX_BYTES = 1_048_576;
|
|
419
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Grace window granted to a log assertion whose wait window stayed
|
|
462
|
+
* empty, measured from the re-attach it triggers. `/logs/tail`
|
|
463
|
+
* resolves readiness on its first status frame and emits the
|
|
464
|
+
* replayed buffer immediately after, so the lines land a round trip
|
|
465
|
+
* — not a device operation — later.
|
|
466
|
+
*/
|
|
467
|
+
export const LOG_STREAM_RESYNC_GRACE_MS = 1_000;
|
|
468
|
+
|
|
420
469
|
/**
|
|
421
470
|
* Default per-call idle timeout (ms) for the `POST /api/build` SSE
|
|
422
471
|
* stream consumed by `requestServerBuild`. Sized for the worst-case
|
package/src/errors.ts
CHANGED
|
@@ -70,7 +70,8 @@ export type DeviceTestErrorCode =
|
|
|
70
70
|
| "SCREENSHOT_BASELINE_MISSING"
|
|
71
71
|
| "SCREENSHOT_DIMENSIONS_MISMATCH"
|
|
72
72
|
| "SCREENSHOT_MASK"
|
|
73
|
-
| "SCREENSHOT_ALIGN_TOLERANCE"
|
|
73
|
+
| "SCREENSHOT_ALIGN_TOLERANCE"
|
|
74
|
+
| "SCREENSHOT_UNSUPPORTED_TARGET";
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Shared base for every error type in this package. New code paths
|
package/src/index.ts
CHANGED
|
@@ -9,32 +9,27 @@
|
|
|
9
9
|
* device resolvers, and sync device-env helpers are no longer part
|
|
10
10
|
* of the documented API.
|
|
11
11
|
*
|
|
12
|
-
* **
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* ONLY via the dedicated, documented internal subpaths:
|
|
12
|
+
* **Harness support entry points.** The `package.json` `exports` map
|
|
13
|
+
* has no `./src/*` wildcard. Symbols kept off this barrel for harness
|
|
14
|
+
* code are on documented `./support/<name>` entry points:
|
|
16
15
|
*
|
|
17
|
-
* - `@roku-sdk/device-test-client/
|
|
18
|
-
* for
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* transitively pulls in
|
|
24
|
-
*
|
|
25
|
-
*
|
|
16
|
+
* - `@roku-sdk/device-test-client/support/deploy` — `resolveAppPath`
|
|
17
|
+
* and `invalidateBuildCacheForApp` for a deploy guard that wipes
|
|
18
|
+
* and rebuilds an app between retries.
|
|
19
|
+
* - `@roku-sdk/device-test-client/support/constants` — the subset of
|
|
20
|
+
* `src/constants.ts` (currently `ARTIFACT_FILE`) that `*.config.ts`
|
|
21
|
+
* files load at parse time. This barrel is unreachable from
|
|
22
|
+
* CommonJS-mode config loaders because it transitively pulls in
|
|
23
|
+
* `@roku-sdk/tooling` ESM (`import.meta.url`); this entry point
|
|
24
|
+
* bypasses that chain.
|
|
25
|
+
* - `@roku-sdk/device-test-client/support/paths` — load-safe
|
|
26
26
|
* `test-results/` path helpers used by multi-device test setup.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
* extending
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* debt that compounded — every `./src/<x>` deep import bound the
|
|
34
|
-
* (already-internal) module topology into the published API surface
|
|
35
|
-
* for consumers using `moduleResolution: "Node16"` or `"Bundler"`.
|
|
36
|
-
* Named subpaths are grep-able, audit-able, and break loudly at
|
|
37
|
-
* install time when a partner reaches for a symbol that has moved.
|
|
28
|
+
* The former `./internal/<name>` subpaths were removed (BREAKING_CHANGES.md).
|
|
29
|
+
* Prefer extending an existing `support/<name>` entry point over
|
|
30
|
+
* minting a parallel one, and never reintroduce the wildcard: every
|
|
31
|
+
* `./src/<x>` deep import bound the module topology into the
|
|
32
|
+
* published API surface.
|
|
38
33
|
*
|
|
39
34
|
* Constants surface (M14): `index.ts` no longer re-exports every
|
|
40
35
|
* value in `constants.ts`. The curated set below covers the env
|
package/src/jest/global-setup.ts
CHANGED
|
@@ -69,6 +69,10 @@ export function createGlobalSetup(opts: GlobalSetupOptions): () => Promise<void>
|
|
|
69
69
|
assertBaselineUpdatesAllowed();
|
|
70
70
|
cleanArtifacts(opts.testResultsDir);
|
|
71
71
|
|
|
72
|
+
// A run through a deployment driver has no physical device to
|
|
73
|
+
// resolve, probe, or lease; each worker builds its own host.
|
|
74
|
+
if (process.env[ENV_VAR.RK_DEVICE_DRIVER_MODULE]) return;
|
|
75
|
+
|
|
72
76
|
const ip = await resolveActiveDeviceIp();
|
|
73
77
|
await persistDeviceIp(opts.testResultsDir, ip);
|
|
74
78
|
process.env[ENV_VAR.RK_DEVICE_IP] = ip;
|
|
@@ -99,7 +99,8 @@ export const HOOK_NAMES = {
|
|
|
99
99
|
DIFF_ROOT: "deviceTestHooks:diffRoot",
|
|
100
100
|
DEVICE_INFO_CACHE: "deviceTestHooks:deviceInfoCache",
|
|
101
101
|
LOG_SESSION_ATTACH: "deviceTestHooks:logSessionAttach",
|
|
102
|
-
LOG_SESSION_DETACH: "deviceTestHooks:logSessionDetach"
|
|
102
|
+
LOG_SESSION_DETACH: "deviceTestHooks:logSessionDetach",
|
|
103
|
+
DRIVER_RELEASE: "deviceTestHooks:driverRelease"
|
|
103
104
|
} as const;
|
|
104
105
|
|
|
105
106
|
/**
|
|
@@ -211,6 +212,7 @@ export function installDeviceTestHooks(opts: InstallDeviceTestHooksOptions = {})
|
|
|
211
212
|
|
|
212
213
|
if (opts.client) {
|
|
213
214
|
const client = opts.client;
|
|
215
|
+
namedAfterAll(HOOK_NAMES.DRIVER_RELEASE, () => client.releaseDriver());
|
|
214
216
|
namedBeforeAll(HOOK_NAMES.DEVICE_INFO_CACHE, async () => {
|
|
215
217
|
if (!client.cachedDeviceInfo()) {
|
|
216
218
|
await client.queryDeviceInfo();
|
package/src/logs/log-buffer.ts
CHANGED
|
@@ -202,15 +202,28 @@ export class LogBuffer {
|
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* First gap overlapping `[from, to ?? currentPosition)`.
|
|
205
|
-
* Half-open
|
|
206
|
-
*
|
|
205
|
+
* Half-open, and includes the currently-open gap so a
|
|
206
|
+
* still-reconnecting stream is detectable mid-wait.
|
|
207
|
+
*
|
|
208
|
+
* `from == to` is an empty *line* range but not an empty
|
|
209
|
+
* question: a wait that received nothing while the stream was
|
|
210
|
+
* reconnecting is the case where "no lines" and "lines we never
|
|
211
|
+
* heard about" are indistinguishable, so an open gap still
|
|
212
|
+
* answers. Only an inverted range answers `null` outright.
|
|
207
213
|
*/
|
|
208
214
|
hasGapWithin(from: LogPosition, to?: LogPosition): Gap | null {
|
|
209
215
|
const fromN = parsePos(from);
|
|
210
216
|
const toN = to !== undefined ? parsePos(to) : this.nextSeq;
|
|
211
|
-
if (toN
|
|
217
|
+
if (toN < fromN) return null;
|
|
218
|
+
// Overlap is the wrong test for an empty range — nothing
|
|
219
|
+
// overlaps it — so ask instead whether a gap contains `fromN`
|
|
220
|
+
// itself, which is what `findGapContaining` means by tainted
|
|
221
|
+
// and what a gap closed at `[fromN, fromN + 1)` records. A gap
|
|
222
|
+
// lying entirely to either side stays excluded either way.
|
|
223
|
+
const empty = toN === fromN;
|
|
212
224
|
for (const g of this.gaps) {
|
|
213
|
-
|
|
225
|
+
const reachesWindow = empty ? fromN >= g.start && fromN < g.end : g.start < toN && g.end > fromN;
|
|
226
|
+
if (reachesWindow) return { ...g };
|
|
214
227
|
}
|
|
215
228
|
if (this.openGapStart !== null) {
|
|
216
229
|
// Open gap extends forward indefinitely; any un-arrived
|
package/src/logs/log-session.ts
CHANGED
|
@@ -72,6 +72,14 @@ export class LogSession {
|
|
|
72
72
|
// Warn at most once per session for post-ready SSE errors so
|
|
73
73
|
// a server burst doesn't flood CI logs.
|
|
74
74
|
private postReadyErrorWarned = false;
|
|
75
|
+
// In-flight `resync()`, shared by concurrent callers.
|
|
76
|
+
private resyncPromise: Promise<void> | null = null;
|
|
77
|
+
// Bumped per `dispose()`, so a re-attach in flight can tell that
|
|
78
|
+
// the caller gave up on the subscription while it was opening.
|
|
79
|
+
private disposeCount = 0;
|
|
80
|
+
// Set by `dispose()`, cleared by an explicit `ensureStarted()`. While
|
|
81
|
+
// set there is no subscription to re-attach, so `resync()` is a no-op.
|
|
82
|
+
private disposed = false;
|
|
75
83
|
|
|
76
84
|
constructor(opts: LogSessionOptions) {
|
|
77
85
|
this.opts = opts;
|
|
@@ -90,6 +98,7 @@ export class LogSession {
|
|
|
90
98
|
* daemon that comes back online isn't permanently rejected.
|
|
91
99
|
*/
|
|
92
100
|
ensureStarted(): Promise<void> {
|
|
101
|
+
this.disposed = false;
|
|
93
102
|
if (this.startPromise) return this.startPromise;
|
|
94
103
|
this.startPromise = this.doStart().catch(err => {
|
|
95
104
|
this.startPromise = null;
|
|
@@ -295,12 +304,65 @@ export class LogSession {
|
|
|
295
304
|
this.postReadyErrorWarned = false;
|
|
296
305
|
}
|
|
297
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Drop the current subscription and open a fresh one.
|
|
309
|
+
*
|
|
310
|
+
* `/logs/tail` replays the whole server-side buffer on attach and
|
|
311
|
+
* `lastServerSeq` drops the duplicates, so a line the previous
|
|
312
|
+
* subscription never delivered still reaches the buffer — and
|
|
313
|
+
* through it any in-flight waiter, since `append` notifies
|
|
314
|
+
* subscribers. A subscription that stops delivering while the
|
|
315
|
+
* server still considers it active reports nothing and closes
|
|
316
|
+
* nothing, so re-attaching is the only way to find out whether
|
|
317
|
+
* the lines exist.
|
|
318
|
+
*
|
|
319
|
+
* Records no gap: a replay fills the window rather than losing
|
|
320
|
+
* it, and an open gap would mark the replayed lines themselves
|
|
321
|
+
* suspect, which is the opposite of the intent. Loss on the
|
|
322
|
+
* device→server leg is the server's own `reconnecting` status,
|
|
323
|
+
* which already marks a gap through `onStatus`.
|
|
324
|
+
*
|
|
325
|
+
* Concurrent callers share one re-attach. After `dispose()` it opens
|
|
326
|
+
* nothing: a waiter whose deadline fires once its test was torn down
|
|
327
|
+
* must not reopen the subscription the teardown closed.
|
|
328
|
+
*/
|
|
329
|
+
resync(): Promise<void> {
|
|
330
|
+
if (this.resyncPromise) return this.resyncPromise;
|
|
331
|
+
if (this.disposed) return Promise.resolve();
|
|
332
|
+
const attempt = (async () => {
|
|
333
|
+
const generation = this.disposeCount;
|
|
334
|
+
this.teardownStream();
|
|
335
|
+
await this.ensureStarted();
|
|
336
|
+
// A `dispose()` that landed while we were attaching wins:
|
|
337
|
+
// the caller asked for no subscription, not a newer one.
|
|
338
|
+
// Without this a worker teardown can be followed by a
|
|
339
|
+
// fresh SSE socket opening behind it.
|
|
340
|
+
if (this.disposeCount !== generation) this.teardownStream();
|
|
341
|
+
})();
|
|
342
|
+
this.resyncPromise = attempt.finally(() => {
|
|
343
|
+
this.resyncPromise = null;
|
|
344
|
+
});
|
|
345
|
+
return this.resyncPromise;
|
|
346
|
+
}
|
|
347
|
+
|
|
298
348
|
/**
|
|
299
349
|
* Close the SSE subscription. Does NOT `/logs/stop` — global
|
|
300
350
|
* teardown owns server-side lifecycle so cross-worker sessions
|
|
301
351
|
* aren't torn down mid-suite.
|
|
302
352
|
*/
|
|
303
353
|
dispose(): void {
|
|
354
|
+
this.disposed = true;
|
|
355
|
+
this.disposeCount += 1;
|
|
356
|
+
this.teardownStream();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Close the stream and clear the memoized session state so the
|
|
361
|
+
* next `ensureStarted()` re-runs `/logs/start` and reopens SSE.
|
|
362
|
+
* The buffer is deliberately untouched: its entries and gap
|
|
363
|
+
* records outlive any one subscription.
|
|
364
|
+
*/
|
|
365
|
+
private teardownStream(): void {
|
|
304
366
|
if (this.stream) {
|
|
305
367
|
try {
|
|
306
368
|
this.stream.close();
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roku, Inc.
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { DeviceTarget } from "@roku-sdk/tooling/src/tooling-utils/device-target";
|
|
4
|
+
|
|
3
5
|
export interface DeviceInfo {
|
|
4
6
|
modelName: string;
|
|
5
7
|
modelNumber: string;
|
|
@@ -369,7 +371,28 @@ export interface ActiveAppInfo {
|
|
|
369
371
|
/** ECP request categories. Keys in `ECP_INTERVAL_MS` must match. */
|
|
370
372
|
export type EcpCategory = "query-appui" | "query-device-info" | "query-active-app" | "keypress" | "launch" | "capture";
|
|
371
373
|
|
|
374
|
+
/**
|
|
375
|
+
* A host that runs fixture apps in place of a physical device. The client
|
|
376
|
+
* deploys through it (instead of the rk server build + installer sideload)
|
|
377
|
+
* and then speaks ECP to the transport it returns. Lets a suite run against
|
|
378
|
+
* any host that answers ECP, without the specs knowing.
|
|
379
|
+
*/
|
|
380
|
+
export interface DeploymentDriver {
|
|
381
|
+
/** Short name used where a device IP would appear (artifact names, messages). */
|
|
382
|
+
readonly label: string;
|
|
383
|
+
/** Answers ECP before the first deploy (e.g. `/query/device-info`). */
|
|
384
|
+
readonly initialTarget: DeviceTarget;
|
|
385
|
+
/** Subscribe to the console lines of whichever app is running; returns the unsubscribe. */
|
|
386
|
+
onLog(listener: (line: string) => void): () => void;
|
|
387
|
+
/** Build and start the app at `absPath`; resolves with the transport to use for it. */
|
|
388
|
+
deploy(request: { absPath: string; launchParams?: Record<string, string> }): Promise<DeviceTarget>;
|
|
389
|
+
/** Stop whatever the driver started. Idempotent. */
|
|
390
|
+
release?(): Promise<void>;
|
|
391
|
+
}
|
|
392
|
+
|
|
372
393
|
export interface RkClientOptions {
|
|
394
|
+
/** Run apps through this driver instead of a device at `deviceIp`. */
|
|
395
|
+
driver?: DeploymentDriver;
|
|
373
396
|
/** Device IPv4. Required directly; `createDefaultClient()` resolves from env/`.roku/`. */
|
|
374
397
|
deviceIp?: string;
|
|
375
398
|
/** Default inter-key delay for `keysequence` (ms). */
|
|
@@ -391,6 +414,12 @@ export interface RkClientOptions {
|
|
|
391
414
|
export interface LogSessionLike {
|
|
392
415
|
ensureStarted(): Promise<void>;
|
|
393
416
|
dispose(): void;
|
|
417
|
+
/**
|
|
418
|
+
* Re-attach the subscription, recovering lines the current one
|
|
419
|
+
* stopped delivering. Optional so an injected fake stays valid
|
|
420
|
+
* without it; callers feature-detect and skip the recovery.
|
|
421
|
+
*/
|
|
422
|
+
resync?(): Promise<void>;
|
|
394
423
|
readonly buffer: LogBufferLike;
|
|
395
424
|
}
|
|
396
425
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@roku-sdk/device-test-client/support/constants`: constants a Jest config can load at parse time.
|
|
6
|
+
*
|
|
7
|
+
* The package root re-exports `RkClient`, which loads `@roku-sdk/tooling`. That package is ESM
|
|
8
|
+
* and uses `import.meta.url`, so a CommonJS-mode loader parsing `jest.config.ts` (ts-node, or the
|
|
9
|
+
* loader Jest uses) fails with `ERR_REQUIRE_ESM` before any test runs. This entry point loads only
|
|
10
|
+
* `src/constants.ts`, whose imports are type-only, so a config file can import from it safely.
|
|
11
|
+
* The same values are also exported from the package root.
|
|
12
|
+
*
|
|
13
|
+
* Only add constants a config loader needs at parse time. The file sits at this physical path so
|
|
14
|
+
* it also resolves under `moduleResolution: "Node"`, which ignores the `exports` map.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { ARTIFACT_FILE, INTEGRATION_BUILD_OUT_DIR } from "../src/constants";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@roku-sdk/device-test-client/support/deploy`: deploy-guard helpers for test harnesses.
|
|
6
|
+
*
|
|
7
|
+
* These are for harness code that recovers from a bad deploy, not for test bodies. A harness
|
|
8
|
+
* that wipes an app's integration build output between deploy retries calls
|
|
9
|
+
* `invalidateBuildCacheForApp` with the same root, so the next `RkClient.buildAndDeploy` rebuilds
|
|
10
|
+
* instead of reusing a memoised result. Call the two together: wiping without invalidating
|
|
11
|
+
* sideloads an empty package, and invalidating without wiping rebuilds for nothing.
|
|
12
|
+
*
|
|
13
|
+
* A harness whose deployment driver sideloads through its own transport (rather than
|
|
14
|
+
* `RkClient.buildAndDeploy`) can reuse the build and manifest steps through `requestServerBuild`,
|
|
15
|
+
* `readAppTypeFromManifest` and `resolveServerUrl`.
|
|
16
|
+
*
|
|
17
|
+
* Importing this entry point loads `@roku-sdk/tooling`, which is ESM, so a CommonJS-mode config
|
|
18
|
+
* loader (ts-node parsing `jest.config.ts`) cannot load it. Config files should use
|
|
19
|
+
* `./support/constants` or `./support/paths`, which are safe to load at parse time.
|
|
20
|
+
*
|
|
21
|
+
* The file sits at this physical path, not just in the `exports` map, so it also resolves under
|
|
22
|
+
* `moduleResolution: "Node"`, which ignores `exports`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export { readAppTypeFromManifest, requestServerBuild, resolveAppPath } from "../src/client/deploy";
|
|
26
|
+
export { resolveServerUrl } from "../src/client/server-url";
|
|
27
|
+
export { invalidateBuildCacheForApp } from "../src/client/build-cache";
|