@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.
@@ -265,14 +265,19 @@ export async function requestServerBuild(serverUrl: string, payload: ServerBuild
265
265
  }
266
266
  }
267
267
 
268
- export function buildLaunchUrl(ip: string, channelId: string, deepLink?: Record<string, string>): string {
269
- const base = `http://${ip}:${ECP_PORT}/launch/${encodeURIComponent(channelId)}`;
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
@@ -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 deviceIp = opts.deviceIp ?? resolveDeviceIpSync(resultsDir);
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);
@@ -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
  }
@@ -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}"`. Returns a
49
- * zero rect on parse failure so callers don't branch on `null`.
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 = /\{\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\}/.exec(raw);
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
- * **Internal symbols (not on the public surface):**
13
- * the `package.json` `exports` map no longer carries a `./src/*`
14
- * wildcard. Symbols deliberately kept off this barrel are reachable
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/internal/deploy` — `resolveAppPath`
18
- * for the integration-test deploy guard's directory-wipe path.
19
- * - `@roku-sdk/device-test-client/internal/constants` — the small
20
- * subset of `src/constants.ts` symbols (currently `ARTIFACT_FILE`)
21
- * that `*.config.ts` files load at parse time. The public barrel
22
- * is unreachable from CommonJS-mode config loaders because it
23
- * transitively pulls in `@roku-sdk/tooling` ESM (`import.meta.url`);
24
- * this subpath bypasses the offending chain.
25
- * - `@roku-sdk/device-test-client/internal/paths` — load-safe
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
- * If a future internal symbol needs the same escape hatch, prefer
29
- * extending one of the existing `internal/<name>` subpaths (grouped
30
- * by concern: deploy-side helpers under `deploy`, config-load
31
- * constants under `constants`) rather than reintroducing the
32
- * wildcard or minting a parallel subpath. The wildcard was tech
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
@@ -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();
@@ -202,15 +202,28 @@ export class LogBuffer {
202
202
 
203
203
  /**
204
204
  * First gap overlapping `[from, to ?? currentPosition)`.
205
- * Half-open: `from == to` is empty. Includes the currently-open
206
- * gap so a still-reconnecting stream is detectable mid-wait.
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 <= fromN) return null;
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
- if (g.start < toN && g.end > fromN) return { ...g };
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
@@ -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";