@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,490 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { setActiveClient, setBaselineRoot } from "../assertions/baselines";
|
|
4
|
+
import { setScreenshotDiffRoot } from "../assertions/screenshot-artifacts";
|
|
5
|
+
import { RkClient } from "../client/client";
|
|
6
|
+
import {
|
|
7
|
+
getFatalReason,
|
|
8
|
+
getUnhealthyReason,
|
|
9
|
+
isDeviceFatal,
|
|
10
|
+
isDeviceHealthy,
|
|
11
|
+
resetDeviceHealth
|
|
12
|
+
} from "../client/health";
|
|
13
|
+
import {
|
|
14
|
+
DEVICE_ENV_GLOBAL_KEY,
|
|
15
|
+
DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
|
|
16
|
+
DEVICE_ONLINE_POLL_INTERVAL_MS,
|
|
17
|
+
DEVICE_RECOVERY_DEFAULT_RETRIES
|
|
18
|
+
} from "../constants";
|
|
19
|
+
import { setDeviceEnvFile } from "../device/device-env-sync";
|
|
20
|
+
import { DeviceUnhealthyError } from "../errors";
|
|
21
|
+
import type { DeviceLogSink } from "../logs/device-log-sink";
|
|
22
|
+
import { attachToBuffer, openDeviceLogSink } from "../logs/device-log-sink";
|
|
23
|
+
|
|
24
|
+
/** Options for `installDeviceTestHooks`. */
|
|
25
|
+
export interface InstallDeviceTestHooksOptions {
|
|
26
|
+
/**
|
|
27
|
+
* `RkClient` for log-session auto-start. Omitting skips auto-start
|
|
28
|
+
* with a console warning. Ignored when `startLogSession: false`.
|
|
29
|
+
*/
|
|
30
|
+
client?: RkClient;
|
|
31
|
+
/** Opt-out for `beforeAll` log-session attach. Defaults to `true`. */
|
|
32
|
+
startLogSession?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Diff PNG output root. Defaults to
|
|
35
|
+
* `<cwd>/test-results/diffs` via the module-level setter.
|
|
36
|
+
*/
|
|
37
|
+
diffRoot?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Baselines directory. Defaults to `DEVICE_TEST_BASELINES` env or
|
|
40
|
+
* `<cwd>/baselines`.
|
|
41
|
+
*/
|
|
42
|
+
baselineRoot?: string;
|
|
43
|
+
/**
|
|
44
|
+
* `device-env.json` path. Defaults to
|
|
45
|
+
* `<cwd>/test-results/device-env.json`. Registered at install time
|
|
46
|
+
* so the sync `readCachedDeviceInfoSync` helper used by
|
|
47
|
+
* registration-time precondition wrappers resolves consistently.
|
|
48
|
+
*/
|
|
49
|
+
deviceEnvFile?: string;
|
|
50
|
+
/**
|
|
51
|
+
* `test-results/` directory. Enables the artifact streams
|
|
52
|
+
* (`device.log` sink + `failures/` bundles) and is exposed to
|
|
53
|
+
* `DeviceTestEnvironment` via `DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR`.
|
|
54
|
+
*/
|
|
55
|
+
testResultsDir?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Opt-out for per-failure bundle capture. The `device.log` sink
|
|
58
|
+
* stays active regardless; this only gates the failure bundle pipeline.
|
|
59
|
+
*/
|
|
60
|
+
captureFailureBundles?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Crash-recovery policy. When enabled, the next `beforeEach` after
|
|
63
|
+
* a failure blocks on `waitForDeviceOnline` and clears the health
|
|
64
|
+
* flag on success (paired with `jest.retryTimes`). Requires
|
|
65
|
+
* `client`; silently demoted to disabled otherwise.
|
|
66
|
+
*/
|
|
67
|
+
deviceRecovery?: DeviceRecoveryOptions | false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DeviceRecoveryOptions {
|
|
71
|
+
/** Master switch; defaults to `true` when `client` is provided. */
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
/** Per-test retry budget; 0 disables retries. */
|
|
74
|
+
retryTimes?: number;
|
|
75
|
+
/** Recovery `waitForDeviceOnline` budget. */
|
|
76
|
+
waitForOnlineTimeoutMs?: number;
|
|
77
|
+
/** Recovery probe interval. */
|
|
78
|
+
pollIntervalMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tag a hook callback with a stable name and register via `beforeAll`.
|
|
83
|
+
* Tests look up the captured callback by `Function.prototype.name` so a
|
|
84
|
+
* future hook addition cannot silently break index-pinned assertions.
|
|
85
|
+
*/
|
|
86
|
+
function namedBeforeAll(name: string, fn: () => unknown): void {
|
|
87
|
+
Object.defineProperty(fn, "name", { value: name, configurable: true });
|
|
88
|
+
beforeAll(fn);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Symmetric counterpart to `namedBeforeAll` for `afterAll` hooks. */
|
|
92
|
+
function namedAfterAll(name: string, fn: () => unknown): void {
|
|
93
|
+
Object.defineProperty(fn, "name", { value: name, configurable: true });
|
|
94
|
+
afterAll(fn);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Stable hook-name registry for test-side `findBeforeAllByName` lookups. */
|
|
98
|
+
export const HOOK_NAMES = {
|
|
99
|
+
DIFF_ROOT: "deviceTestHooks:diffRoot",
|
|
100
|
+
DEVICE_INFO_CACHE: "deviceTestHooks:deviceInfoCache",
|
|
101
|
+
LOG_SESSION_ATTACH: "deviceTestHooks:logSessionAttach",
|
|
102
|
+
LOG_SESSION_DETACH: "deviceTestHooks:logSessionDetach",
|
|
103
|
+
DRIVER_RELEASE: "deviceTestHooks:driverRelease"
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Module-level latch that flips on the first successful
|
|
108
|
+
* `installDeviceTestHooks` call. A second call WITHIN THE SAME
|
|
109
|
+
* MODULE INSTANCE would double-register `beforeAll` / `beforeEach` /
|
|
110
|
+
* `afterAll` hooks AND re-issue `jest.retryTimes`, doubling the
|
|
111
|
+
* per-test retry budget. The first symptom is mysterious "this
|
|
112
|
+
* test ran four times" reports; the second is doubled log
|
|
113
|
+
* subscribes leaking memory.
|
|
114
|
+
*
|
|
115
|
+
* IMPORTANT — scope is per-test-file, NOT per-Node-process:
|
|
116
|
+
*
|
|
117
|
+
* Jest creates a fresh module registry for every test file, even
|
|
118
|
+
* under `--runInBand`. `setupFilesAfterEnv` re-evaluates the setup
|
|
119
|
+
* script for each file, which re-imports this module → `HAS_INSTALLED`
|
|
120
|
+
* starts as `false` on every file. That is intentional: every test
|
|
121
|
+
* file legitimately needs its own `beforeAll(ensureLogSession)`
|
|
122
|
+
* because Jest's `beforeAll` is file-scoped.
|
|
123
|
+
*
|
|
124
|
+
* What this latch DOES protect against, then, is a duplicate
|
|
125
|
+
* registration WITHIN THE SAME FILE — almost always a misconfigured
|
|
126
|
+
* `setupFilesAfterEnv` array that lists the setup script twice, or
|
|
127
|
+
* a downstream consumer that imports + calls `installDeviceTestHooks`
|
|
128
|
+
* in addition to the standard setup file.
|
|
129
|
+
*
|
|
130
|
+
* Intentional design choices:
|
|
131
|
+
* - Warn-and-no-op (not throw): identical observable behaviour
|
|
132
|
+
* keeps CI green during the first run while the operator
|
|
133
|
+
* investigates the duplicate registration.
|
|
134
|
+
* - `__resetForTests` exists ONLY for the package's own unit
|
|
135
|
+
* tests; production code MUST NOT touch it.
|
|
136
|
+
*/
|
|
137
|
+
let HAS_INSTALLED = false;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Test-only: clear the install latch so a unit test can call
|
|
141
|
+
* `installDeviceTestHooks` again and observe its hook registrations.
|
|
142
|
+
* Underscored to discourage accidental import in production.
|
|
143
|
+
*/
|
|
144
|
+
export function __resetInstallDeviceTestHooksForTests(): void {
|
|
145
|
+
HAS_INSTALLED = false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Registers per-worker Jest hooks. Must be called from
|
|
150
|
+
* `setupFilesAfterEnv` so Jest's lifecycle globals are in scope.
|
|
151
|
+
*
|
|
152
|
+
* Install-time (synchronous): `setBaselineRoot` / `setDeviceEnvFile` /
|
|
153
|
+
* `setActiveClient` (registration-time wrappers read these from
|
|
154
|
+
* top-level test code, before `beforeAll` fires); `TEST_RESULTS_DIR` /
|
|
155
|
+
* `CAPTURE_ENABLED` globals (read by `DeviceTestEnvironment`); and
|
|
156
|
+
* `jest.retryTimes(...)` when recovery is enabled.
|
|
157
|
+
*
|
|
158
|
+
* Hooks: `beforeAll` for diff-root, log-session attach (subscribes the
|
|
159
|
+
* `device.log` sink BEFORE `ensureLogSession` so no replay line is
|
|
160
|
+
* dropped); `beforeEach` cascade guard (sync without recovery, async
|
|
161
|
+
* `waitForDeviceOnline` with); `afterAll` log-session detach (unsubs
|
|
162
|
+
* sink first, then `disposeLogSession` — does NOT call `/logs/stop`,
|
|
163
|
+
* which `createGlobalTeardown` owns); `afterEach` no-op (per-test
|
|
164
|
+
* failure artifacts are owned by `DeviceTestEnvironment.handleTestEvent`).
|
|
165
|
+
*/
|
|
166
|
+
export function installDeviceTestHooks(opts: InstallDeviceTestHooksOptions = {}): void {
|
|
167
|
+
if (HAS_INSTALLED) {
|
|
168
|
+
// Loud no-op: see HAS_INSTALLED comment above for rationale.
|
|
169
|
+
// Set RK_ALLOW_REINSTALL_HOOKS=1 only when intentionally
|
|
170
|
+
// exercising hook registration twice (e.g. test harness).
|
|
171
|
+
if (process.env.RK_ALLOW_REINSTALL_HOOKS !== "1") {
|
|
172
|
+
console.warn(
|
|
173
|
+
"[device-test-client] installDeviceTestHooks() called more than once in the same Node process. " +
|
|
174
|
+
"Ignoring the second call to avoid double-registering Jest hooks (which would double the per-test " +
|
|
175
|
+
"retry budget and leak duplicate log subscribers). Check that `setupFilesAfterEnv` lists this " +
|
|
176
|
+
"package's setup file only once. Set RK_ALLOW_REINSTALL_HOOKS=1 to bypass."
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
HAS_INSTALLED = true;
|
|
182
|
+
const startLogSession = opts.startLogSession !== false;
|
|
183
|
+
const captureFailureBundles = opts.captureFailureBundles !== false;
|
|
184
|
+
const recovery = resolveRecoveryConfig(opts);
|
|
185
|
+
|
|
186
|
+
// Synchronous install-time registration: registration-time wrappers
|
|
187
|
+
// (`itOnDevice` / `describeOnDevice`) read these from top-level test
|
|
188
|
+
// code, which Jest evaluates BEFORE `beforeAll` fires.
|
|
189
|
+
if (opts.baselineRoot !== undefined) {
|
|
190
|
+
setBaselineRoot(opts.baselineRoot);
|
|
191
|
+
}
|
|
192
|
+
if (opts.deviceEnvFile !== undefined) {
|
|
193
|
+
setDeviceEnvFile(opts.deviceEnvFile);
|
|
194
|
+
}
|
|
195
|
+
if (opts.client) {
|
|
196
|
+
setActiveClient(opts.client);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// No `beforeAll(resetDeviceHealth)`: the recovery `beforeEach`
|
|
200
|
+
// below has the only correct cross-suite semantics (probe before
|
|
201
|
+
// reset). A `beforeAll` reset would either bypass the probe or
|
|
202
|
+
// mask the operator signal.
|
|
203
|
+
|
|
204
|
+
// Diff-root in its own `beforeAll` so screenshot-only suites get it
|
|
205
|
+
// even without a log-session client.
|
|
206
|
+
if (opts.diffRoot !== undefined) {
|
|
207
|
+
const diffRoot = opts.diffRoot;
|
|
208
|
+
namedBeforeAll(HOOK_NAMES.DIFF_ROOT, () => {
|
|
209
|
+
setScreenshotDiffRoot(diffRoot);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (opts.client) {
|
|
214
|
+
const client = opts.client;
|
|
215
|
+
namedAfterAll(HOOK_NAMES.DRIVER_RELEASE, () => client.releaseDriver());
|
|
216
|
+
namedBeforeAll(HOOK_NAMES.DEVICE_INFO_CACHE, async () => {
|
|
217
|
+
if (!client.cachedDeviceInfo()) {
|
|
218
|
+
await client.queryDeviceInfo();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Expose `testResultsDir` to `DeviceTestEnvironment` via globals;
|
|
224
|
+
// env reads at `teardown`, so install-time write is visible in time.
|
|
225
|
+
if (captureFailureBundles && opts.testResultsDir !== undefined) {
|
|
226
|
+
const testResultsDir = opts.testResultsDir;
|
|
227
|
+
(globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR] = testResultsDir;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Set synchronously so the first `test_fn_failure` event already
|
|
231
|
+
// observes the final value.
|
|
232
|
+
(globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.CAPTURE_ENABLED] = captureFailureBundles;
|
|
233
|
+
|
|
234
|
+
if (startLogSession && opts.client) {
|
|
235
|
+
const client = opts.client;
|
|
236
|
+
// Closure-scoped so multiple `installDeviceTestHooks` calls
|
|
237
|
+
// with different configs don't share state.
|
|
238
|
+
let sink: DeviceLogSink | null = null;
|
|
239
|
+
let unsubscribe: (() => void) | null = null;
|
|
240
|
+
|
|
241
|
+
namedBeforeAll(HOOK_NAMES.LOG_SESSION_ATTACH, async () => {
|
|
242
|
+
// Order matters: subscribe BEFORE `ensureLogSession()` so the
|
|
243
|
+
// replay batch and any live lines land in `device.log`. The
|
|
244
|
+
// buffer is constructed eagerly so `client.logBuffer()` is
|
|
245
|
+
// safe before `ensureStarted()`.
|
|
246
|
+
if (opts.testResultsDir !== undefined) {
|
|
247
|
+
try {
|
|
248
|
+
sink = openDeviceLogSink(opts.testResultsDir, client.deviceIp);
|
|
249
|
+
unsubscribe = attachToBuffer(sink, client.logBuffer());
|
|
250
|
+
(globalThis as Record<string, unknown>)[DEVICE_ENV_GLOBAL_KEY.LOG_SINK] = sink;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// Non-fatal: CI loses `device.log` for this run but
|
|
253
|
+
// the suite still proceeds.
|
|
254
|
+
console.warn(
|
|
255
|
+
`[device-test-client] device.log sink failed to open: ${(err as Error).message}. ` +
|
|
256
|
+
`Run artifacts will be incomplete.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await client.ensureLogSession();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// Non-fatal: the first log-assertion call retries and
|
|
265
|
+
// surfaces the same error with full context.
|
|
266
|
+
console.warn(
|
|
267
|
+
`[device-test-client] ensureLogSession() failed in beforeAll: ${(err as Error).message}. ` +
|
|
268
|
+
`Log assertions will retry on first call.`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
namedAfterAll(HOOK_NAMES.LOG_SESSION_DETACH, async () => {
|
|
274
|
+
// Unsubscribe FIRST so in-flight appends don't write to a
|
|
275
|
+
// stream that's about to flush.
|
|
276
|
+
if (unsubscribe) {
|
|
277
|
+
try {
|
|
278
|
+
unsubscribe();
|
|
279
|
+
} catch {
|
|
280
|
+
// best-effort
|
|
281
|
+
}
|
|
282
|
+
unsubscribe = null;
|
|
283
|
+
}
|
|
284
|
+
if (sink) {
|
|
285
|
+
try {
|
|
286
|
+
await sink.close();
|
|
287
|
+
} catch {
|
|
288
|
+
// best-effort
|
|
289
|
+
}
|
|
290
|
+
sink = null;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
client.disposeLogSession();
|
|
294
|
+
} catch {
|
|
295
|
+
// best-effort
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Synchronous so tests declared in the same `setupFilesAfterEnv`
|
|
301
|
+
// pass observe the budget. `jest` may be undefined under ts-jest
|
|
302
|
+
// unit-tests of this module — guard.
|
|
303
|
+
if (
|
|
304
|
+
recovery.enabled &&
|
|
305
|
+
recovery.retryTimes > 0 &&
|
|
306
|
+
typeof jest !== "undefined" &&
|
|
307
|
+
typeof jest.retryTimes === "function"
|
|
308
|
+
) {
|
|
309
|
+
// `logErrorsBeforeRetry: true` distinguishes "flaky" from
|
|
310
|
+
// "genuinely broken" by surfacing per-attempt errors.
|
|
311
|
+
jest.retryTimes(recovery.retryTimes, { logErrorsBeforeRetry: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Recovery OFF keeps `beforeEach` sync so unit tests that invoke
|
|
315
|
+
// the captured hook directly observe throw-on-sync behaviour.
|
|
316
|
+
if (recovery.enabled && recovery.client) {
|
|
317
|
+
beforeEach(async () => {
|
|
318
|
+
// FATAL latch is checked FIRST and unconditionally — before the
|
|
319
|
+
// soft-health early-return below. Jest gives every test FILE a
|
|
320
|
+
// fresh module registry (even under `--runInBand`), so the
|
|
321
|
+
// module-local `deviceHealthy` resets to `true` at the start of
|
|
322
|
+
// each file; gating on `isDeviceHealthy()` first would
|
|
323
|
+
// early-return on that fresh-file path and never consult the
|
|
324
|
+
// latch. `isDeviceFatal()` rehydrates from the on-disk sentinel
|
|
325
|
+
// (see health.ts), the only state that survives the reset, so a
|
|
326
|
+
// wedge proved by an earlier file aborts this file's tests
|
|
327
|
+
// immediately instead of probing + retrying a VM that can never
|
|
328
|
+
// foreground a channel. A fatally-wedged VM still answers
|
|
329
|
+
// `/query/device-info`, so the liveness probe below would
|
|
330
|
+
// FALSELY "recover" it — recovery is skipped by design and the
|
|
331
|
+
// instance must be recycled at the lease/runner layer.
|
|
332
|
+
if (isDeviceFatal()) {
|
|
333
|
+
const reason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
|
|
334
|
+
throw new DeviceUnhealthyError(
|
|
335
|
+
`Aborting test: device is fatally wedged — ${reason}. ` +
|
|
336
|
+
`It answers ECP/installer liveness probes but will not foreground a ` +
|
|
337
|
+
`sideloaded channel, so device recovery is skipped (it cannot help). ` +
|
|
338
|
+
`The device host must be recycled. Remaining tests in this worker ` +
|
|
339
|
+
`abort-fast. Per-failure diagnostics are written to ` +
|
|
340
|
+
`test-results/failures/<sanitised-test-path>/ — the FIRST failure in the ` +
|
|
341
|
+
`bundle carries the root cause.`,
|
|
342
|
+
reason
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (isDeviceHealthy()) return;
|
|
346
|
+
const reason = getUnhealthyReason() ?? "unknown";
|
|
347
|
+
// If `/query/device-info` answers, clear the flag and let
|
|
348
|
+
// `jest.retryTimes` re-execute the crashing test.
|
|
349
|
+
const waitResult = await tryWaitForDeviceOnline(recovery);
|
|
350
|
+
if (waitResult.ok) {
|
|
351
|
+
console.warn(
|
|
352
|
+
`[device-test-client] Device recovered after "${reason}" ` +
|
|
353
|
+
`(attempts=${waitResult.attempts}, elapsedMs=${waitResult.elapsedMs}). ` +
|
|
354
|
+
`Clearing unhealthy flag and continuing.`
|
|
355
|
+
);
|
|
356
|
+
resetDeviceHealth();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Fall through to the cascade error, but enrich it so the
|
|
360
|
+
// reviewer sees WHY recovery didn't help.
|
|
361
|
+
throw new DeviceUnhealthyError(
|
|
362
|
+
`Aborting test: device marked unhealthy — ${reason}. ` +
|
|
363
|
+
`Recovery attempted via waitForDeviceOnline and failed ` +
|
|
364
|
+
`after ${waitResult.elapsedMs}ms (${waitResult.attempts} probes): ` +
|
|
365
|
+
`${waitResult.errorMessage}. Remaining tests in this worker will ` +
|
|
366
|
+
`abort-fast. Per-failure diagnostics are written to ` +
|
|
367
|
+
`test-results/failures/<sanitised-test-path>/ — the ` +
|
|
368
|
+
`FIRST failure in the bundle carries the root cause.`,
|
|
369
|
+
reason
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
} else {
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
// Cross-file FATAL latch first (see the recovery branch above
|
|
375
|
+
// for why order matters): `isDeviceFatal()` rehydrates from the
|
|
376
|
+
// on-disk sentinel, so a wedge proved by an earlier test file
|
|
377
|
+
// aborts this fresh file even though its module-local
|
|
378
|
+
// `deviceHealthy` reset to `true`. The dedicated message names
|
|
379
|
+
// the wedge so triage doesn't mistake it for an ordinary
|
|
380
|
+
// cascade.
|
|
381
|
+
if (isDeviceFatal()) {
|
|
382
|
+
const fatalReason = getFatalReason() ?? getUnhealthyReason() ?? "unknown";
|
|
383
|
+
throw new DeviceUnhealthyError(
|
|
384
|
+
`Aborting test: device is fatally wedged — ${fatalReason}. ` +
|
|
385
|
+
`It answers ECP/installer liveness probes but will not foreground a ` +
|
|
386
|
+
`sideloaded channel; retrying cannot recover it. The device host ` +
|
|
387
|
+
`must be recycled. Remaining tests in this worker abort-fast. Per-failure ` +
|
|
388
|
+
`diagnostics are written to test-results/failures/<sanitised-test-path>/ ` +
|
|
389
|
+
`— the FIRST failure in the bundle carries the root cause.`,
|
|
390
|
+
fatalReason
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (isDeviceHealthy()) return;
|
|
394
|
+
// The cascade error's stack points HERE, not at the test
|
|
395
|
+
// that broke the device — surface the per-failure bundle
|
|
396
|
+
// path inline so reviewers find the root-cause artifacts.
|
|
397
|
+
const reason = getUnhealthyReason() ?? "unknown";
|
|
398
|
+
throw new DeviceUnhealthyError(
|
|
399
|
+
`Aborting test: device marked unhealthy — ${reason}. ` +
|
|
400
|
+
`Remaining tests in this worker will also abort-fast to ` +
|
|
401
|
+
`prevent cascading failures. Per-failure diagnostics ` +
|
|
402
|
+
`(screenshot, dom-tree, recent logs, error) are written to ` +
|
|
403
|
+
`test-results/failures/<sanitised-test-path>/ — the ` +
|
|
404
|
+
`FIRST failure in the bundle carries the root cause.`,
|
|
405
|
+
reason
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
afterEach(() => {
|
|
411
|
+
// No-op anchor. Per-test failure artifacts are written by
|
|
412
|
+
// `DeviceTestEnvironment.handleTestEvent` since `afterEach`
|
|
413
|
+
// cannot observe failure state without a side-channel flag.
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Internal: fully-resolved recovery config. */
|
|
418
|
+
interface ResolvedRecoveryConfig {
|
|
419
|
+
enabled: boolean;
|
|
420
|
+
retryTimes: number;
|
|
421
|
+
waitForOnlineTimeoutMs: number;
|
|
422
|
+
pollIntervalMs: number;
|
|
423
|
+
client?: RkClient;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Normalise `deviceRecovery` into a concrete config. Opt-in: defaults
|
|
428
|
+
* OFF (legacy fast-fail cascade). "Enabled but no client" silently
|
|
429
|
+
* demotes to disabled rather than throwing — the client is often
|
|
430
|
+
* wired in later in the same `jest-setup.ts`.
|
|
431
|
+
*/
|
|
432
|
+
function resolveRecoveryConfig(opts: InstallDeviceTestHooksOptions): ResolvedRecoveryConfig {
|
|
433
|
+
const client = opts.client;
|
|
434
|
+
const raw = opts.deviceRecovery;
|
|
435
|
+
if (raw === false || raw === undefined) {
|
|
436
|
+
return {
|
|
437
|
+
enabled: false,
|
|
438
|
+
retryTimes: 0,
|
|
439
|
+
waitForOnlineTimeoutMs: DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
|
|
440
|
+
pollIntervalMs: DEVICE_ONLINE_POLL_INTERVAL_MS,
|
|
441
|
+
client
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const enabledRequested = raw.enabled ?? true;
|
|
445
|
+
const enabled = enabledRequested && client !== undefined;
|
|
446
|
+
return {
|
|
447
|
+
enabled,
|
|
448
|
+
retryTimes: raw.retryTimes ?? DEVICE_RECOVERY_DEFAULT_RETRIES,
|
|
449
|
+
waitForOnlineTimeoutMs: raw.waitForOnlineTimeoutMs ?? DEVICE_ONLINE_DEFAULT_TIMEOUT_MS,
|
|
450
|
+
pollIntervalMs: raw.pollIntervalMs ?? DEVICE_ONLINE_POLL_INTERVAL_MS,
|
|
451
|
+
client
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
interface RecoveryAttemptResult {
|
|
456
|
+
ok: boolean;
|
|
457
|
+
attempts: number;
|
|
458
|
+
elapsedMs: number;
|
|
459
|
+
errorMessage: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Wrap `waitForDeviceOnline` with a never-throw result shape so a
|
|
464
|
+
* recovery-layer bug cannot evict the original cascade message.
|
|
465
|
+
*/
|
|
466
|
+
async function tryWaitForDeviceOnline(cfg: ResolvedRecoveryConfig): Promise<RecoveryAttemptResult> {
|
|
467
|
+
const startedAt = Date.now();
|
|
468
|
+
if (!cfg.client) {
|
|
469
|
+
return { ok: false, attempts: 0, elapsedMs: 0, errorMessage: "no client configured" };
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
await cfg.client.waitForDeviceOnline({
|
|
473
|
+
timeoutMs: cfg.waitForOnlineTimeoutMs,
|
|
474
|
+
intervalMs: cfg.pollIntervalMs
|
|
475
|
+
});
|
|
476
|
+
return { ok: true, attempts: 1, elapsedMs: Date.now() - startedAt, errorMessage: "" };
|
|
477
|
+
} catch (err) {
|
|
478
|
+
const attempts =
|
|
479
|
+
typeof err === "object" && err && "context" in err
|
|
480
|
+
? ((err as { context?: { attempts?: number } }).context?.attempts ?? 0)
|
|
481
|
+
: 0;
|
|
482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
attempts,
|
|
486
|
+
elapsedMs: Date.now() - startedAt,
|
|
487
|
+
errorMessage: message
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Barrel re-exports — `jest-setup.ts` was historically the single
|
|
5
|
+
* module hosting every Jest orchestration symbol shipped by this
|
|
6
|
+
* package. The implementations now live in cohesive leaf modules:
|
|
7
|
+
*
|
|
8
|
+
* - `artifacts.ts` — `cleanArtifacts`, `assertSupportedVideoMode`,
|
|
9
|
+
* `writeDeviceEnvSnapshot`, `logDeviceDiagnostic`.
|
|
10
|
+
* - `lease-provisioning.ts` — `readLeaseProvisioning` (lease-recorded
|
|
11
|
+
* provenance folded into `device-env.json`).
|
|
12
|
+
* - `global-setup.ts` — `createGlobalSetup`, `GlobalSetupOptions`.
|
|
13
|
+
* - `global-teardown.ts` — `createGlobalTeardown`, `CreateGlobalTeardownOptions`.
|
|
14
|
+
* - `install-hooks.ts` — `installDeviceTestHooks`, `InstallDeviceTestHooksOptions`.
|
|
15
|
+
* - `baseline-env-guard.ts` — `assertBaselineUpdatesAllowed`, `isCi`.
|
|
16
|
+
* - `telemetry.ts` — device-lease log line + JUnit `<properties>` writer.
|
|
17
|
+
*
|
|
18
|
+
* This file preserves the original import surface so existing callers
|
|
19
|
+
* (`device-tests/setup.ts`, `__tests__/jest-setup.test.ts`, and any
|
|
20
|
+
* downstream consumer) continue to work unchanged. New code should
|
|
21
|
+
* import directly from the leaf modules above.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { assertBaselineUpdatesAllowed, isCi } from "../assertions/baseline-env-guard";
|
|
25
|
+
export { assertSupportedVideoMode, cleanArtifacts, logDeviceDiagnostic, writeDeviceEnvSnapshot } from "./artifacts";
|
|
26
|
+
export type { DeviceEnvSnapshot } from "./artifacts";
|
|
27
|
+
export { readLeaseProvisioning } from "../device/lease-provisioning";
|
|
28
|
+
export type { LeaseProvisioning } from "../device/lease-provisioning";
|
|
29
|
+
export { createGlobalSetup } from "./global-setup";
|
|
30
|
+
export type { GlobalSetupOptions } from "./global-setup";
|
|
31
|
+
export { createGlobalTeardown } from "./global-teardown";
|
|
32
|
+
export type { CreateGlobalTeardownOptions } from "./global-teardown";
|
|
33
|
+
export { installDeviceTestHooks } from "./install-hooks";
|
|
34
|
+
export type { InstallDeviceTestHooksOptions } from "./install-hooks";
|
|
35
|
+
export {
|
|
36
|
+
buildAcquireEvent,
|
|
37
|
+
buildJUnitTelemetryProperties,
|
|
38
|
+
buildReleaseEvent,
|
|
39
|
+
DEFAULT_PROVIDER_KIND,
|
|
40
|
+
DEFAULT_SHARD_NAME,
|
|
41
|
+
DEVICE_LEASE_LOG_PREFIX,
|
|
42
|
+
emitDeviceLeaseLog,
|
|
43
|
+
formatDeviceLeaseLog,
|
|
44
|
+
resolveTelemetry,
|
|
45
|
+
writeJUnitTelemetryPropertiesFile
|
|
46
|
+
} from "./telemetry";
|
|
47
|
+
export type {
|
|
48
|
+
DeviceLeaseAcquireEvent,
|
|
49
|
+
DeviceLeaseEvent,
|
|
50
|
+
DeviceLeaseReleaseEvent,
|
|
51
|
+
DeviceLeaseTelemetry,
|
|
52
|
+
JUnitTelemetryProperties
|
|
53
|
+
} from "./telemetry";
|