@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,296 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Tiny SSE consumer for `POST /logs/tail`. Avoids a dependency for
5
+ * ~30 lines of framing logic (auditable in failure postmortems).
6
+ *
7
+ * Wire format: `event: <type>\ndata: <payload>\n\n` where payload is
8
+ * raw for `log` events and JSON for `status`/`error`.
9
+ *
10
+ * Handles CRLF normalisation, chunk-split frames (pending buffer
11
+ * dispatched on `\n\n`), and multi-line `data:` continuations.
12
+ */
13
+
14
+ import http from "node:http";
15
+ import https from "node:https";
16
+ import { URL } from "node:url";
17
+
18
+ import { SSE_PENDING_MAX_BYTES } from "../constants";
19
+
20
+ export interface LogStreamCallbacks {
21
+ /**
22
+ * `event: log` frame. `serverSeq` is the parsed `id:` field, used
23
+ * by `LogSession` to dedup replay batches; `undefined` when the
24
+ * server omits it (older builds).
25
+ */
26
+ onLine(line: string, serverSeq?: number): void;
27
+ /** `event: status` frame. `lines` is best-effort. */
28
+ onStatus(status: string, lines?: number): void;
29
+ /** `event: error` frames and fatal transport errors. */
30
+ onError(message: string): void;
31
+ /**
32
+ * Server-initiated transport termination (response `end` w/o
33
+ * `status: stopped`, or post-connect socket error). NOT invoked
34
+ * for local `close()` — teardown stays silent.
35
+ */
36
+ onClose?(): void;
37
+ }
38
+
39
+ export interface LogStreamClientOptions {
40
+ /** Base URL of the `rk server` daemon (e.g. `http://localhost:3737`). */
41
+ serverUrl: string;
42
+ /** Device selector (ip/id/name); omit → server's active device. */
43
+ device?: string;
44
+ /** Roku log port override (default server-side: 8085). */
45
+ port?: number;
46
+ /** Test-only body override. */
47
+ body?: string;
48
+ }
49
+
50
+ type ParsedEvent = { event: string; data: string; id?: string };
51
+
52
+ export class LogStreamClient {
53
+ private readonly opts: LogStreamClientOptions;
54
+ private readonly cbs: LogStreamCallbacks;
55
+ private controller: AbortController | null = null;
56
+ private req: http.ClientRequest | null = null;
57
+ private closed = false;
58
+ // True iff close() was invoked locally; suppresses onClose so
59
+ // user-initiated teardown stays silent.
60
+ private userClosed = false;
61
+ // Guards onClose from firing more than once across end + error.
62
+ private onCloseFired = false;
63
+ private pending = "";
64
+
65
+ constructor(opts: LogStreamClientOptions, cbs: LogStreamCallbacks) {
66
+ this.opts = opts;
67
+ this.cbs = cbs;
68
+ }
69
+
70
+ /**
71
+ * Open POST + SSE subscription. Synchronous; HTTP dial happens
72
+ * in the background, errors surface via `cbs.onError`. Callers
73
+ * gate on the first `onStatus("active", ...)` for "connected".
74
+ */
75
+ open(): void {
76
+ if (this.controller) return; // already open
77
+ this.controller = new AbortController();
78
+ const body =
79
+ this.opts.body ??
80
+ JSON.stringify({
81
+ ...(this.opts.device ? { device: this.opts.device } : {}),
82
+ ...(this.opts.port !== undefined ? { port: this.opts.port } : {})
83
+ });
84
+
85
+ // Don't use `new URL("/logs/tail", base)` — WHATWG URL
86
+ // resolution drops the base's path on a leading `/`. Our
87
+ // base is `http://host:port/api`; concatenate to preserve.
88
+ let url: URL;
89
+ try {
90
+ const base = this.opts.serverUrl.endsWith("/") ? this.opts.serverUrl.slice(0, -1) : this.opts.serverUrl;
91
+ url = new URL(`${base}/logs/tail`);
92
+ } catch (err) {
93
+ this.cbs.onError(`LogStreamClient: invalid serverUrl "${this.opts.serverUrl}": ${(err as Error).message}`);
94
+ return;
95
+ }
96
+
97
+ const lib = url.protocol === "https:" ? https : http;
98
+ const req = lib.request(
99
+ {
100
+ protocol: url.protocol,
101
+ hostname: url.hostname,
102
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
103
+ path: url.pathname + url.search,
104
+ method: "POST",
105
+ headers: {
106
+ Accept: "text/event-stream",
107
+ "Content-Type": "application/json",
108
+ "Content-Length": Buffer.byteLength(body, "utf8"),
109
+ "Cache-Control": "no-cache"
110
+ },
111
+ signal: this.controller.signal
112
+ },
113
+ res => {
114
+ // Non-2xx → surface to session manager for retry
115
+ // decision. Guard against late arrival post-close.
116
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
117
+ if (this.closed) return;
118
+ this.cbs.onError(`LogStreamClient: /logs/tail returned HTTP ${res.statusCode ?? "?"}.`);
119
+ return;
120
+ }
121
+ res.setEncoding("utf8");
122
+ res.on("data", chunk => this.ingest(chunk));
123
+ res.on("end", () => {
124
+ this.flushPending();
125
+ const wasUserClosed = this.userClosed;
126
+ this.closed = true;
127
+ if (!wasUserClosed) this.fireOnClose();
128
+ });
129
+ res.on("error", err => {
130
+ if (this.closed) return;
131
+ this.cbs.onError(`LogStreamClient: response stream error: ${err.message}`);
132
+ // Post-connect socket error is a remote close.
133
+ this.closed = true;
134
+ if (!this.userClosed) this.fireOnClose();
135
+ });
136
+ }
137
+ );
138
+
139
+ req.on("error", err => {
140
+ if (this.closed) return;
141
+ // AbortError from close() is expected; suppress noise.
142
+ if ((err as NodeJS.ErrnoException).name === "AbortError") return;
143
+ this.cbs.onError(`LogStreamClient: request error: ${err.message}`);
144
+ });
145
+
146
+ req.write(body);
147
+ req.end();
148
+ this.req = req;
149
+ }
150
+
151
+ /** Abort the HTTP request. Idempotent. */
152
+ close(): void {
153
+ if (this.closed) return;
154
+ // Flag BEFORE aborting so abort/end handlers see the
155
+ // user-initiated flavour and suppress onClose.
156
+ this.userClosed = true;
157
+ this.closed = true;
158
+ try {
159
+ this.controller?.abort();
160
+ } catch {
161
+ // swallow — abort is best-effort
162
+ }
163
+ try {
164
+ this.req?.destroy();
165
+ } catch {
166
+ // swallow
167
+ }
168
+ this.controller = null;
169
+ this.req = null;
170
+ }
171
+
172
+ /** Fire `onClose` at most once; swallow handler errors. */
173
+ private fireOnClose(): void {
174
+ if (this.onCloseFired) return;
175
+ this.onCloseFired = true;
176
+ try {
177
+ this.cbs.onClose?.();
178
+ } catch {
179
+ // callback errors are not actionable here
180
+ }
181
+ }
182
+
183
+ /** Ingest a chunk of SSE text and dispatch completed events. */
184
+ private ingest(chunk: string): void {
185
+ if (this.closed) return;
186
+ this.pending += chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
187
+ let idx: number;
188
+ while ((idx = this.pending.indexOf("\n\n")) !== -1) {
189
+ const raw = this.pending.slice(0, idx);
190
+ this.pending = this.pending.slice(idx + 2);
191
+ const parsed = this.parseFrame(raw);
192
+ if (parsed) this.dispatch(parsed);
193
+ }
194
+ // Fail loud if the server streams without frame terminators
195
+ // so the worker doesn't OOM. Treat as user-close so onClose
196
+ // is silent — re-attaching would just repeat the pathology.
197
+ if (this.pending.length > SSE_PENDING_MAX_BYTES) {
198
+ if (this.closed) return;
199
+ const size = this.pending.length;
200
+ this.pending = "";
201
+ this.cbs.onError(
202
+ `LogStreamClient: SSE frame exceeded ${SSE_PENDING_MAX_BYTES} bytes without terminator (pending=${size}).`
203
+ );
204
+ this.close();
205
+ }
206
+ }
207
+
208
+ /** Flush trailing bytes at stream end (rarely has a complete frame). */
209
+ private flushPending(): void {
210
+ if (!this.pending) return;
211
+ const raw = this.pending;
212
+ this.pending = "";
213
+ const parsed = this.parseFrame(raw);
214
+ if (parsed) this.dispatch(parsed);
215
+ }
216
+
217
+ /**
218
+ * Minimal SSE frame parser. Ignores `:` comments and `retry:`.
219
+ * `data:` lines join with `\n`; `id:` last-wins per spec.
220
+ */
221
+ private parseFrame(raw: string): ParsedEvent | null {
222
+ let event = "message";
223
+ let id: string | undefined;
224
+ const dataParts: string[] = [];
225
+ for (const line of raw.split("\n")) {
226
+ if (!line || line.startsWith(":")) continue;
227
+ const colon = line.indexOf(":");
228
+ const field = colon === -1 ? line : line.slice(0, colon);
229
+ // SSE spec: if a space follows the colon, skip it.
230
+ let value = colon === -1 ? "" : line.slice(colon + 1);
231
+ if (value.startsWith(" ")) value = value.slice(1);
232
+ if (field === "event") event = value;
233
+ else if (field === "data") dataParts.push(value);
234
+ else if (field === "id") id = value;
235
+ // ignore retry / other
236
+ }
237
+ if (dataParts.length === 0) return null;
238
+ return { event, data: dataParts.join("\n"), id };
239
+ }
240
+
241
+ private dispatch(evt: ParsedEvent): void {
242
+ if (this.closed) return;
243
+ try {
244
+ if (evt.event === "log") {
245
+ // Server emits `{"line":"..."}`; fall back to raw
246
+ // payload on parse failure so we make progress on
247
+ // malformed events.
248
+ const line = extractLine(evt.data);
249
+ const serverSeq = parseSeq(evt.id);
250
+ this.cbs.onLine(line, serverSeq);
251
+ } else if (evt.event === "status") {
252
+ const parsed = tryParseJson(evt.data);
253
+ const status = typeof parsed?.status === "string" ? parsed.status : "";
254
+ const lines = typeof parsed?.lines === "number" ? parsed.lines : undefined;
255
+ if (status) this.cbs.onStatus(status, lines);
256
+ } else if (evt.event === "error") {
257
+ const parsed = tryParseJson(evt.data);
258
+ const msg = (typeof parsed?.message === "string" && parsed.message) || evt.data || "unknown SSE error";
259
+ this.cbs.onError(msg);
260
+ }
261
+ // other event types: ignored
262
+ } catch (err) {
263
+ this.cbs.onError(`LogStreamClient: dispatch error: ${(err as Error).message}`);
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Extract `line` field from a log event. Falls back to raw `data`
270
+ * so the parser doesn't wedge on malformed payloads.
271
+ */
272
+ function extractLine(data: string): string {
273
+ const parsed = tryParseJson(data);
274
+ if (parsed && typeof parsed.line === "string") return parsed.line;
275
+ return data;
276
+ }
277
+
278
+ function tryParseJson(data: string): Record<string, unknown> | null {
279
+ try {
280
+ const v = JSON.parse(data);
281
+ return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Parse SSE `id:` as a non-negative integer; `undefined` for
289
+ * missing/invalid (callers fall back to first-seen-wins).
290
+ */
291
+ function parseSeq(id: string | undefined): number | undefined {
292
+ if (id === undefined || id === "") return undefined;
293
+ const n = Number(id);
294
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return undefined;
295
+ return n;
296
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Internal path helpers. Not re-exported from `src/index.ts` — these
5
+ * are implementation details for call sites that compose a default
6
+ * path to the consumer's `test-results/` directory.
7
+ *
8
+ * Why this module exists:
9
+ * Before this file, three call sites (`device-env-sync.ts`,
10
+ * `client.ts`, `device-test-environment.ts`) each embedded the
11
+ * string `"test-results"` inline. A rename (e.g. to `"artifacts/"`)
12
+ * would have silently split call sites. Centralising here keeps
13
+ * future renames to a one-file change.
14
+ *
15
+ * Kept out of the public barrel on purpose: the consumer's own
16
+ * `jest.config.ts` / `setup.ts` pass an explicit `testResultsDir` to
17
+ * `createGlobalSetup` / `installDeviceTestHooks`, so the default is
18
+ * a library-internal fallback, not a documented API surface.
19
+ */
20
+
21
+ /**
22
+ * Directory name (relative to the consumer's repo root / `process.cwd()`)
23
+ * where Jest artifacts, baselines metadata, and the device-env snapshot
24
+ * are written. Every call site that composes a default path to the
25
+ * consumer's test-results directory MUST go through this constant so
26
+ * that a future rename is a one-file change.
27
+ */
28
+ export const TEST_RESULTS_DIR_NAME = "test-results";
29
+
30
+ import { isAbsolute } from "node:path";
31
+
32
+ /**
33
+ * Absolute per-run artifact root used by the multi-device shard runner.
34
+ * When set, it MUST override the historical `<cwd>/test-results` default
35
+ * everywhere worker-side code reads globalSetup artifacts (`.device-ip`,
36
+ * `device-env.json`, junit helpers). Keeping this env name in the package
37
+ * rather than only in `device-tests/testUtils/test-results-dir.ts` prevents
38
+ * the split-brain failure where globalSetup writes to one directory while
39
+ * `createDefaultClient()` reads from another.
40
+ */
41
+ export const TEST_RESULTS_DIR_ENV_VAR = "RK_TEST_RESULTS_DIR";
42
+
43
+ /**
44
+ * Compose an absolute path to the consumer's test-results directory.
45
+ *
46
+ * Precedence:
47
+ * 1. `RK_TEST_RESULTS_DIR` absolute path (multi-device runner).
48
+ * 2. Historical default: `<rootDir>/test-results`.
49
+ *
50
+ * Returned string uses POSIX forward-slashes for the fallback to match
51
+ * the pre-existing behaviour of `client.ts`' IP-handoff path composition.
52
+ */
53
+ export function defaultTestResultsDir(rootDir: string = process.cwd()): string {
54
+ const fromEnv = process.env[TEST_RESULTS_DIR_ENV_VAR];
55
+ if (fromEnv && fromEnv.length > 0) {
56
+ if (!isAbsolute(fromEnv)) {
57
+ throw new Error(
58
+ `[device-test-client] ${TEST_RESULTS_DIR_ENV_VAR} must be an absolute path; got "${fromEnv}". ` +
59
+ `Relative paths would split globalSetup artifacts from worker-side readers.`
60
+ );
61
+ }
62
+ return fromEnv;
63
+ }
64
+ return `${rootDir}/${TEST_RESULTS_DIR_NAME}`;
65
+ }