@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.
Files changed (61) hide show
  1. package/BREAKING_CHANGES.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -2
  4. package/docs/baselines.md +221 -0
  5. package/docs/configuration.md +258 -0
  6. package/docs/dom-assertions.md +264 -0
  7. package/docs/errors.md +544 -0
  8. package/docs/examples/deep-link-cold-start.md +85 -0
  9. package/docs/examples/focus-navigation.md +81 -0
  10. package/docs/examples/log-driven-assertion.md +99 -0
  11. package/docs/examples/precondition-skips.md +150 -0
  12. package/docs/examples/screenshot-regression.md +103 -0
  13. package/docs/examples/smoke-test.md +69 -0
  14. package/docs/getting-started.md +195 -0
  15. package/docs/jest-integration.md +331 -0
  16. package/docs/log-assertions.md +349 -0
  17. package/docs/preconditions.md +317 -0
  18. package/docs/rkclient.md +657 -0
  19. package/docs/screenshot-assertions.md +316 -0
  20. package/package.json +81 -3
  21. package/src/assertions/assertions.ts +661 -0
  22. package/src/assertions/baseline-env-guard.ts +43 -0
  23. package/src/assertions/baselines.ts +178 -0
  24. package/src/assertions/screenshot-artifacts.ts +39 -0
  25. package/src/assertions/screenshot-diff.ts +447 -0
  26. package/src/client/build-cache.ts +292 -0
  27. package/src/client/client.ts +1964 -0
  28. package/src/client/deploy.ts +407 -0
  29. package/src/client/ecp-queue.ts +112 -0
  30. package/src/client/ecp-retry.ts +94 -0
  31. package/src/client/factory.ts +170 -0
  32. package/src/client/health.ts +345 -0
  33. package/src/client/parsers.ts +162 -0
  34. package/src/client/server-url.ts +137 -0
  35. package/src/constants.ts +520 -0
  36. package/src/device/device-class.ts +57 -0
  37. package/src/device/device-env-sync.ts +58 -0
  38. package/src/device/device-resolver-sync.ts +43 -0
  39. package/src/device/device-resolver.ts +141 -0
  40. package/src/device/lease-provisioning.ts +54 -0
  41. package/src/errors.ts +774 -0
  42. package/src/index.ts +181 -0
  43. package/src/internal/constants.ts +3 -0
  44. package/src/jest/artifacts.ts +87 -0
  45. package/src/jest/device-test-environment.ts +420 -0
  46. package/src/jest/failure-artifacts.ts +169 -0
  47. package/src/jest/global-setup.ts +137 -0
  48. package/src/jest/global-teardown.ts +132 -0
  49. package/src/jest/install-hooks.ts +490 -0
  50. package/src/jest/jest-setup.ts +53 -0
  51. package/src/jest/preconditions.ts +239 -0
  52. package/src/jest/telemetry.ts +331 -0
  53. package/src/logs/device-log-sink.ts +69 -0
  54. package/src/logs/log-buffer.ts +278 -0
  55. package/src/logs/log-session.ts +436 -0
  56. package/src/logs/log-stream-client.ts +296 -0
  57. package/src/paths.ts +65 -0
  58. package/src/types.ts +472 -0
  59. package/support/constants.ts +17 -0
  60. package/support/deploy.ts +27 -0
  61. 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
+ }