@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,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
|
+
}
|