@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,292 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* In-process build memoization for `RkClient.buildAndDeploy`.
|
|
5
|
+
*
|
|
6
|
+
* **Why this exists.** Each device-test file
|
|
7
|
+
* registers its own `beforeAll` deploy via `installDeployGuard`,
|
|
8
|
+
* which calls `RkClient.buildAndDeploy(appPath, opts)`. A single
|
|
9
|
+
* Jest `--runInBand` shard often deploys the same fixture from
|
|
10
|
+
|
|
11
|
+
* many test files — e.g. the `internal-runtime` shard re-builds `apps/internal-runtime`
|
|
12
|
+
|
|
13
|
+
* eight times for the eight `runtime/*.test.ts` siblings. Source
|
|
14
|
+
* files do not change between those `beforeAll` blocks, so the
|
|
15
|
+
* second through eighth `requestServerBuild()` calls are pure
|
|
16
|
+
* waste: rk-server happily re-bundles, re-instruments, re-packages
|
|
17
|
+
* an identical input on every call. Even a 20s build amortises to
|
|
18
|
+
* ~2–3 min of redundant wall-clock per shard.
|
|
19
|
+
*
|
|
20
|
+
* This module caches the BUILD step only. Sideload + launch still
|
|
21
|
+
* run on every `buildAndDeploy` invocation — the device's state
|
|
22
|
+
* between two test files is unknown and a fresh sideload is the
|
|
23
|
+
* cheapest way to guarantee a clean slate.
|
|
24
|
+
*
|
|
25
|
+
* ## Cache key
|
|
26
|
+
*
|
|
27
|
+
* The key is the tuple `(absRoot, outDir, buildType, dropConsole)`.
|
|
28
|
+
* These are exactly the inputs of `requestServerBuild`'s payload
|
|
29
|
+
* that affect the produced `pkg-dir`. `deviceSelector`, `channelId`,
|
|
30
|
+
* `deepLink`, `readyNode`, `readinessTimeout` are not part of the
|
|
31
|
+
* key because they are deploy/launch concerns; mutating any of them
|
|
32
|
+
* across calls MUST NOT skip the rebuild (and does not, because
|
|
33
|
+
* none of them is in the key).
|
|
34
|
+
*
|
|
35
|
+
* `dropConsole === undefined` is canonicalised to `null` so the
|
|
36
|
+
* three states (`true`, `false`, omitted) hash to three distinct
|
|
37
|
+
* keys. This matches `requestServerBuild`'s server-side merge
|
|
38
|
+
* semantics: an omitted value lets `roku-config.ts` win, while an
|
|
39
|
+
* explicit value overrides — those are different inputs and must
|
|
40
|
+
* be different cache buckets.
|
|
41
|
+
*
|
|
42
|
+
* ## Failure semantics
|
|
43
|
+
*
|
|
44
|
+
* Cache entries store the in-flight `Promise<void>`, not the
|
|
45
|
+
* resolved value. On rejection the entry is removed from the map
|
|
46
|
+
* BEFORE the rejection propagates, so a transient build failure
|
|
47
|
+
* does not poison subsequent retries. The deploy-guard's retry
|
|
48
|
+
* loop relies on this: a failing first attempt must be retryable
|
|
49
|
+
* without first wiping the cache by hand.
|
|
50
|
+
*
|
|
51
|
+
* Storing the Promise also gives us free deduplication for
|
|
52
|
+
* concurrent callers: if two `beforeAll` hooks race the same key
|
|
53
|
+
* (rare under `--runInBand`, but cheap to support and forward-
|
|
54
|
+
* compatible with future `maxWorkers > 1` modes), both await the
|
|
55
|
+
* same in-flight `requestServerBuild`.
|
|
56
|
+
*
|
|
57
|
+
* ## Invalidation
|
|
58
|
+
*
|
|
59
|
+
* Two explicit paths exist:
|
|
60
|
+
*
|
|
61
|
+
* - `invalidateBuildCacheForApp(absRoot)` — removes every entry
|
|
62
|
+
* whose `absRoot` matches. Called by
|
|
63
|
+
* `device-tests/testUtils/deploy-guard.ts`'s
|
|
64
|
+
* `wipeIntegrationBuildOutput` right after it `rm -rf`s
|
|
65
|
+
* `<absRoot>/dist-integration-test/`. Pairing the two means a
|
|
66
|
+
* "the previous attempt's bundle is dirty" recovery path also
|
|
67
|
+
* invalidates the memoized "we already built this" assertion.
|
|
68
|
+
* - `__resetBuildCacheForTests()` — clears everything. Wired
|
|
69
|
+
* into unit-test `beforeEach` so cache state never leaks
|
|
70
|
+
* across test cases in the same Jest worker.
|
|
71
|
+
*
|
|
72
|
+
* IMPORTANT: different keys can still write the same physical output
|
|
73
|
+
* directory. Example: `buildType=never-ready` and `buildType=build`
|
|
74
|
+
* both write `<absRoot>/dist-integration-test/pkg-dir`. Therefore a
|
|
75
|
+
* cache miss that is about to build one key must evict any sibling
|
|
76
|
+
* entries with the same `(absRoot, outDir)` before the build writes
|
|
77
|
+
* to disk. Otherwise a later hit for the old key would skip rebuild
|
|
78
|
+
* and sideload bytes produced by the newer sibling build.
|
|
79
|
+
*
|
|
80
|
+
* Exported via `./support/deploy` (not the public barrel) because
|
|
81
|
+
* it is a harness helper, not something test bodies call. Treat any
|
|
82
|
+
* change here as a change to that entry point and the deploy-guard
|
|
83
|
+
* contract together.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cache-key components surfaced to the call site. `dropConsole`
|
|
88
|
+
* accepts the explicit-`undefined` arm so callers can pass
|
|
89
|
+
* `opts?.dropConsole` directly without pre-normalising.
|
|
90
|
+
*/
|
|
91
|
+
export interface BuildCacheKeyInput {
|
|
92
|
+
/** Absolute path to the app root that rk-server will build. */
|
|
93
|
+
absRoot: string;
|
|
94
|
+
/** `outDir` relative to `absRoot` (currently always
|
|
95
|
+
* `dist-integration-test`, but keyed explicitly so a future
|
|
96
|
+
* out-dir change cannot silently reuse a stale entry). */
|
|
97
|
+
outDir: string;
|
|
98
|
+
/** Value forwarded as `buildType` in the server payload —
|
|
99
|
+
* derived from `process.env.ROKU_BUILD_TYPE` with `"build"`
|
|
100
|
+
* fallback. */
|
|
101
|
+
buildType: string;
|
|
102
|
+
/** Tri-state: `true` / `false` / `undefined` (omitted). */
|
|
103
|
+
dropConsole: boolean | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Whether the build was requested with development artifacts
|
|
106
|
+
* (no minification, source maps). A dev build and a prod build
|
|
107
|
+
* of the same app must not share a cache entry.
|
|
108
|
+
*/
|
|
109
|
+
dev?: boolean;
|
|
110
|
+
/** Whether SolidJS dev mode was requested (`--solid-dev`). */
|
|
111
|
+
solidDevMode?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Build-time variable overrides. Different overrides produce
|
|
114
|
+
* different bundles (e.g. `__SIGNAL__: true` switches to the
|
|
115
|
+
* SolidJS dev build) so they must be part of the cache key.
|
|
116
|
+
*/
|
|
117
|
+
buildVariables?: Record<string, boolean | string | number>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute the deterministic string key for a build-cache lookup.
|
|
122
|
+
* Exported (named, no underscore) so the unit tests can derive the
|
|
123
|
+
* same key directly and assert the canonicalisation rules — and so
|
|
124
|
+
* a future caller that wants to peek at the cache outside
|
|
125
|
+
* `getOrCreateBuildPromise` does not have to re-implement the
|
|
126
|
+
* shape contract.
|
|
127
|
+
*
|
|
128
|
+
* **Key shape (stable contract):** newline-separated `field=value`
|
|
129
|
+
* pairs in a fixed order. The line-based layout — chosen over
|
|
130
|
+
* `JSON.stringify` — sidesteps any future key-order change in V8's
|
|
131
|
+
* object-literal serialisation and stays grep-friendly in debug
|
|
132
|
+
* logs. The fixed field order is the public contract: appending a
|
|
133
|
+
* new field is a minor (older keys still match), reordering
|
|
134
|
+
* existing fields is a breaking cache flush.
|
|
135
|
+
*/
|
|
136
|
+
export function computeBuildCacheKey(input: BuildCacheKeyInput): string {
|
|
137
|
+
// Canonicalise `dropConsole`: the `undefined` arm collapses to
|
|
138
|
+
// the literal string `"omitted"` so a forgotten `?? null` at a
|
|
139
|
+
// call site cannot create a key collision with `dropConsole: null`
|
|
140
|
+
// (which would otherwise stringify the same as our sentinel).
|
|
141
|
+
const dropConsoleKey = input.dropConsole === undefined ? "omitted" : input.dropConsole ? "true" : "false";
|
|
142
|
+
// Canonicalise buildVariables: sort keys so `{ a, b }` and `{ b, a }` hash the same.
|
|
143
|
+
const buildVarsKey = input.buildVariables
|
|
144
|
+
? Object.keys(input.buildVariables)
|
|
145
|
+
.sort()
|
|
146
|
+
.map(k => `${k}=${input.buildVariables![k]}`)
|
|
147
|
+
.join(",")
|
|
148
|
+
: "omitted";
|
|
149
|
+
return [
|
|
150
|
+
`root=${input.absRoot}`,
|
|
151
|
+
`outDir=${input.outDir}`,
|
|
152
|
+
`buildType=${input.buildType}`,
|
|
153
|
+
`dropConsole=${dropConsoleKey}`,
|
|
154
|
+
`dev=${input.dev ? "true" : "false"}`,
|
|
155
|
+
`solidDevMode=${input.solidDevMode ? "true" : "false"}`,
|
|
156
|
+
`buildVariables=${buildVarsKey}`
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface BuildCacheEntry {
|
|
161
|
+
/** Absolute app root the entry was created for. Carried on the
|
|
162
|
+
* entry so `invalidateBuildCacheForApp(absRoot)` can match
|
|
163
|
+
* without re-parsing the composite key. */
|
|
164
|
+
readonly absRoot: string;
|
|
165
|
+
/** Out directory relative to `absRoot`. Entries with the same
|
|
166
|
+
* `(absRoot, outDir)` point at the same on-disk pkg-dir even when
|
|
167
|
+
* their logical build inputs differ (e.g. `buildType`). */
|
|
168
|
+
readonly outDir: string;
|
|
169
|
+
/** In-flight or already-resolved promise from `requestServerBuild`.
|
|
170
|
+
* Rejected promises are removed from the cache BEFORE the
|
|
171
|
+
* rejection settles to the original caller (see the catch
|
|
172
|
+
* handler installed in `getOrCreateBuildPromise`). */
|
|
173
|
+
readonly promise: Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const buildCache = new Map<string, BuildCacheEntry>();
|
|
177
|
+
|
|
178
|
+
function invalidateSiblingOutputEntries(keyToKeep: string, keyInput: BuildCacheKeyInput): void {
|
|
179
|
+
for (const [key, entry] of buildCache) {
|
|
180
|
+
if (key !== keyToKeep && entry.absRoot === keyInput.absRoot && entry.outDir === keyInput.outDir) {
|
|
181
|
+
buildCache.delete(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run `factory()` exactly once per `(absRoot, outDir, buildType,
|
|
188
|
+
* dropConsole)` combination within this process. Subsequent calls
|
|
189
|
+
* with the same key await the cached promise without re-invoking
|
|
190
|
+
* `factory`.
|
|
191
|
+
*
|
|
192
|
+
* The factory is responsible for performing the actual build work
|
|
193
|
+
* (`requestServerBuild`). Resolution / rejection propagate verbatim
|
|
194
|
+
* to every caller awaiting the same key; on rejection the entry
|
|
195
|
+
* is dropped so the very next call re-enters the factory rather
|
|
196
|
+
* than re-throwing forever.
|
|
197
|
+
*
|
|
198
|
+
* On cache hit a single `[device-test-client] build cache hit: …`
|
|
199
|
+
* line is emitted to stdout — meaningful triage signal for
|
|
200
|
+
* "why did the runtime shard finish 3 min faster on this run"
|
|
201
|
+
* without flooding the log (each shard fires this O(#test-files)
|
|
202
|
+
* times at most, ~5–10 total).
|
|
203
|
+
*/
|
|
204
|
+
export function getOrCreateBuildPromise(keyInput: BuildCacheKeyInput, factory: () => Promise<void>): Promise<void> {
|
|
205
|
+
const key = computeBuildCacheKey(keyInput);
|
|
206
|
+
const existing = buildCache.get(key);
|
|
207
|
+
if (existing) {
|
|
208
|
+
console.log(`[device-test-client] build cache hit: ${keyInput.absRoot}`);
|
|
209
|
+
return existing.promise;
|
|
210
|
+
}
|
|
211
|
+
// A cache miss is about to write `<absRoot>/<outDir>/pkg-dir`.
|
|
212
|
+
// Remove any older logical variants pointing at that same output
|
|
213
|
+
// directory first. Without this, `buildType=build` can overwrite
|
|
214
|
+
// the pkg-dir produced by `buildType=never-ready` while the
|
|
215
|
+
// never-ready cache entry remains "valid" and later sideloads the
|
|
216
|
+
// wrong bytes.
|
|
217
|
+
invalidateSiblingOutputEntries(key, keyInput);
|
|
218
|
+
// Wrap the factory invocation in a try/catch so a synchronous
|
|
219
|
+
// throw (e.g. a programming error in the factory closure) is
|
|
220
|
+
// converted to a rejected promise — without this, the cache
|
|
221
|
+
// would never have stored the failure and any caller racing the
|
|
222
|
+
// same key would re-enter the failing factory.
|
|
223
|
+
let promise: Promise<void>;
|
|
224
|
+
try {
|
|
225
|
+
promise = factory();
|
|
226
|
+
} catch (err) {
|
|
227
|
+
promise = Promise.reject(err);
|
|
228
|
+
}
|
|
229
|
+
const entry: BuildCacheEntry = {
|
|
230
|
+
absRoot: keyInput.absRoot,
|
|
231
|
+
outDir: keyInput.outDir,
|
|
232
|
+
promise: promise.catch(err => {
|
|
233
|
+
// Remove the failed entry FIRST so a retry that races
|
|
234
|
+
// the rejection settling still goes through to a fresh
|
|
235
|
+
// factory invocation. `Map.delete` is keyed by the
|
|
236
|
+
// composite string we computed above, so we close over
|
|
237
|
+
// `key` rather than relying on entry identity.
|
|
238
|
+
buildCache.delete(key);
|
|
239
|
+
throw err;
|
|
240
|
+
})
|
|
241
|
+
};
|
|
242
|
+
buildCache.set(key, entry);
|
|
243
|
+
// Return the wrapped promise (not the raw `promise`) so the
|
|
244
|
+
// failure-cleanup `.catch` always runs even when the original
|
|
245
|
+
// caller awaits and immediately exits on error. Awaiters of
|
|
246
|
+
// both the original and the cached entry observe the same
|
|
247
|
+
// rejection.
|
|
248
|
+
return entry.promise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Remove every cache entry whose `absRoot` matches the argument.
|
|
253
|
+
*
|
|
254
|
+
* Called by `device-tests/testUtils/deploy-guard.ts` immediately
|
|
255
|
+
* after wiping `<absRoot>/dist-integration-test/`. The wipe runs
|
|
256
|
+
* between deploy retries to defend against a half-written manifest
|
|
257
|
+
* left by a crashed rk-server build; if we kept the memoized
|
|
258
|
+
* promise around, a retry inside the same Jest run would "succeed"
|
|
259
|
+
* by skipping the build entirely — and then sideload from an
|
|
260
|
+
* empty pkg-dir.
|
|
261
|
+
*
|
|
262
|
+
* Per-`absRoot` granularity (rather than `clearAll`) is deliberate:
|
|
263
|
+
* a recovery loop for one fixture should not flush the entries for
|
|
264
|
+
* unrelated fixtures already cached in the same Jest run.
|
|
265
|
+
*/
|
|
266
|
+
export function invalidateBuildCacheForApp(absRoot: string): void {
|
|
267
|
+
for (const [key, entry] of buildCache) {
|
|
268
|
+
if (entry.absRoot === absRoot) {
|
|
269
|
+
buildCache.delete(key);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Test-only escape hatch — clear every cache entry. Unit tests
|
|
276
|
+
* call this in `beforeEach` so cache state never leaks across
|
|
277
|
+
* cases in the same Jest worker. Production code MUST NOT call
|
|
278
|
+
* this; use `invalidateBuildCacheForApp` for per-`absRoot`
|
|
279
|
+
* eviction instead.
|
|
280
|
+
*/
|
|
281
|
+
export function __resetBuildCacheForTests(): void {
|
|
282
|
+
buildCache.clear();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Test-only — number of live entries currently in the cache.
|
|
287
|
+
* Exposed for assertions like "the second call did NOT add a new
|
|
288
|
+
* entry" without inspecting the private map directly.
|
|
289
|
+
*/
|
|
290
|
+
export function __buildCacheSizeForTests(): number {
|
|
291
|
+
return buildCache.size;
|
|
292
|
+
}
|