@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,169 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Pure-fs helpers for writing per-failure bundles to
|
|
5
|
+
* `<root>/failures/<sanitized>/`. Each sub-write is independent
|
|
6
|
+
* (best-effort): a failing `screenshot.png` write doesn't block
|
|
7
|
+
* the others. Leaf module — no `client.ts`/`assertions.ts` imports.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { FAILURE_ARTIFACT_FILE, FAILURES_DIR } from "../constants";
|
|
14
|
+
import type { LogEntry } from "../logs/log-buffer";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Join `[describe..., testName]` into a single filesystem-safe
|
|
18
|
+
* directory name. Each part: non-`[A-Za-z0-9._-]` → `-`, runs
|
|
19
|
+
* collapsed, edges trimmed. Parts joined with `--` so reviewers
|
|
20
|
+
* can recover describe boundaries by eye. Falls back to
|
|
21
|
+
* `"unnamed-test"` for empty input.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeTestPath(parts: readonly string[]): string {
|
|
24
|
+
const cleaned = parts.map(sanitizePart).filter(p => p.length > 0);
|
|
25
|
+
if (cleaned.length === 0) return "unnamed-test";
|
|
26
|
+
return cleaned.join("--");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sanitizePart(s: string): string {
|
|
30
|
+
return s
|
|
31
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
32
|
+
.replace(/-+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Create `<testResultsDir>/failures/` and return its absolute path. */
|
|
37
|
+
export function ensureFailuresDir(testResultsDir: string): string {
|
|
38
|
+
const dir = join(testResultsDir, FAILURES_DIR);
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Inputs for a single failure bundle. Every field may be null —
|
|
45
|
+
* a client unavailable at teardown still gets an `error.txt`.
|
|
46
|
+
*/
|
|
47
|
+
export interface FailureBundleInput {
|
|
48
|
+
screenshot: Buffer | null;
|
|
49
|
+
domTree: unknown;
|
|
50
|
+
/** Empty array is valid and writes an empty `recent-logs.txt`. */
|
|
51
|
+
recentLogs: ReadonlyArray<Pick<LogEntry, "seq" | "line">> | null;
|
|
52
|
+
/** `LogStreamHealth` snapshot for transport-leg attribution. */
|
|
53
|
+
streamHealth?: Record<string, unknown> | null;
|
|
54
|
+
error: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-bundle write outcome. Caller surfaces a single warn instead
|
|
59
|
+
* of letting per-write errors propagate into Jest teardown.
|
|
60
|
+
*/
|
|
61
|
+
export interface FailureBundleWriteResult {
|
|
62
|
+
written: string[];
|
|
63
|
+
failed: Array<{ artifact: string; cause: Error }>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write every available artifact to `dir`. Each sub-write is
|
|
68
|
+
* independently try/caught (best-effort). `error.txt` is always
|
|
69
|
+
* attempted — a bundle with only `error.txt` is still useful.
|
|
70
|
+
*/
|
|
71
|
+
export function writeFailureBundle(dir: string, input: FailureBundleInput): FailureBundleWriteResult {
|
|
72
|
+
const written: string[] = [];
|
|
73
|
+
const failed: Array<{ artifact: string; cause: Error }> = [];
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// mkdir failure → all artifacts fail uniformly; one entry.
|
|
79
|
+
failed.push({ artifact: "mkdir", cause: toError(err) });
|
|
80
|
+
return { written, failed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (input.screenshot) {
|
|
84
|
+
const name = FAILURE_ARTIFACT_FILE.SCREENSHOT;
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(join(dir, name), input.screenshot);
|
|
87
|
+
written.push(name);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
failed.push({ artifact: "screenshot", cause: toError(err) });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (input.domTree !== null && input.domTree !== undefined) {
|
|
94
|
+
const name = FAILURE_ARTIFACT_FILE.DOM_TREE;
|
|
95
|
+
try {
|
|
96
|
+
const body = `${JSON.stringify(input.domTree, null, 2)}\n`;
|
|
97
|
+
writeFileSync(join(dir, name), body, "utf8");
|
|
98
|
+
written.push(name);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
failed.push({ artifact: "dom-tree", cause: toError(err) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (input.recentLogs !== null) {
|
|
105
|
+
const name = FAILURE_ARTIFACT_FILE.RECENT_LOGS;
|
|
106
|
+
try {
|
|
107
|
+
const body = formatRecentLogs(input.recentLogs);
|
|
108
|
+
writeFileSync(join(dir, name), body, "utf8");
|
|
109
|
+
written.push(name);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
failed.push({ artifact: "recent-logs", cause: toError(err) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.streamHealth !== null && input.streamHealth !== undefined) {
|
|
116
|
+
const name = FAILURE_ARTIFACT_FILE.STREAM_HEALTH;
|
|
117
|
+
try {
|
|
118
|
+
const body = `${JSON.stringify(input.streamHealth, null, 2)}\n`;
|
|
119
|
+
writeFileSync(join(dir, name), body, "utf8");
|
|
120
|
+
written.push(name);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
failed.push({ artifact: "stream-health", cause: toError(err) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const errorName = FAILURE_ARTIFACT_FILE.ERROR;
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(join(dir, errorName), `${stringifyError(input.error)}\n`, "utf8");
|
|
129
|
+
written.push(errorName);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
failed.push({ artifact: "error", cause: toError(err) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { written, failed };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatRecentLogs(logs: ReadonlyArray<Pick<LogEntry, "seq" | "line">>): string {
|
|
138
|
+
if (logs.length === 0) return "";
|
|
139
|
+
return `${logs.map(e => `${e.seq}\t${e.line}`).join("\n")}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render `err` for `error.txt`. Duck-types on `.stack`/`.message`
|
|
144
|
+
* because Jest VM-sandbox errors don't pass `instanceof Error`
|
|
145
|
+
* across the realm boundary; `JSON.stringify` would omit `.stack`.
|
|
146
|
+
* Precedence: stack > name: message > string > JSON > String().
|
|
147
|
+
*/
|
|
148
|
+
function stringifyError(err: unknown): string {
|
|
149
|
+
if (typeof err === "string") return err;
|
|
150
|
+
if (err !== null && typeof err === "object") {
|
|
151
|
+
const e = err as { stack?: unknown; message?: unknown; name?: unknown };
|
|
152
|
+
if (typeof e.stack === "string" && e.stack.length > 0) {
|
|
153
|
+
return e.stack;
|
|
154
|
+
}
|
|
155
|
+
if (typeof e.message === "string" && e.message.length > 0) {
|
|
156
|
+
const name = typeof e.name === "string" && e.name.length > 0 ? e.name : "Error";
|
|
157
|
+
return `${name}: ${e.message}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return JSON.stringify(err, null, 2);
|
|
162
|
+
} catch {
|
|
163
|
+
return String(err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toError(err: unknown): Error {
|
|
168
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
169
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* `createGlobalSetup` — factory for Jest's `globalSetup` config.
|
|
5
|
+
* Runs once per suite, in its own process, before any worker
|
|
6
|
+
* imports a test file. `jest-setup.ts` re-exports for API
|
|
7
|
+
* compatibility.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { assertBaselineUpdatesAllowed } from "../assertions/baseline-env-guard";
|
|
13
|
+
import { createDefaultClient } from "../client/factory";
|
|
14
|
+
import { ARTIFACT_FILE, ENV_VAR, ROKU_LOG_PORT } from "../constants";
|
|
15
|
+
import { persistDeviceIp, resolveActiveDeviceIp } from "../device/device-resolver";
|
|
16
|
+
import { readLeaseProvisioning } from "../device/lease-provisioning";
|
|
17
|
+
import {
|
|
18
|
+
assertSupportedVideoMode,
|
|
19
|
+
cleanArtifacts,
|
|
20
|
+
logDeviceDiagnostic,
|
|
21
|
+
trimTrailingSlash,
|
|
22
|
+
writeDeviceEnvSnapshot
|
|
23
|
+
} from "./artifacts";
|
|
24
|
+
import {
|
|
25
|
+
buildAcquireEvent,
|
|
26
|
+
emitDeviceLeaseLog,
|
|
27
|
+
resolveTelemetry,
|
|
28
|
+
writeJUnitTelemetryPropertiesFile
|
|
29
|
+
} from "./telemetry";
|
|
30
|
+
|
|
31
|
+
export interface GlobalSetupOptions {
|
|
32
|
+
/** Absolute path to the consumer's `test-results/` directory. */
|
|
33
|
+
testResultsDir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ECP budget for the startup readiness gate, deliberately tighter than
|
|
38
|
+
* the client-wide `DEVICE_ONLINE_DEFAULT_TIMEOUT_MS` (which is sized for
|
|
39
|
+
* crash/reboot recovery mid-run). By the time `globalSetup` runs the
|
|
40
|
+
* lease layer has already waited for ECP, so a device that still isn't
|
|
41
|
+
* answering is misconfigured or powered off — failing in 30s keeps that
|
|
42
|
+
* diagnosis fast for local runs instead of hanging for minutes.
|
|
43
|
+
*/
|
|
44
|
+
const STARTUP_ECP_TIMEOUT_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the `globalSetup` function:
|
|
48
|
+
* 1. Reject `UPDATE_BASELINES=1` in CI.
|
|
49
|
+
* 2. Clean stale artifacts (keeps `.gitkeep`).
|
|
50
|
+
* 3. Resolve device IP and persist `test-results/.device-ip`
|
|
51
|
+
* (workers read synchronously). Also exports `RK_DEVICE_IP`.
|
|
52
|
+
* 4. Block until the device answers ECP *and* accepts TCP on the
|
|
53
|
+
* sideload installer port; validate `videoMode`.
|
|
54
|
+
* 5. Persist `device-env.json` — the ECP snapshot plus the lease's
|
|
55
|
+
* provisioning provenance; workers seed cache from it.
|
|
56
|
+
* 6. Log one-line diagnostic.
|
|
57
|
+
* 7. Reset + start `rk server` log session (best-effort; a
|
|
58
|
+
* missing daemon is logged not thrown).
|
|
59
|
+
* 8. Emit the `[device-lease]` acquire log line and write the
|
|
60
|
+
* JUnit per-`<testcase>` properties file (`device-id`,
|
|
61
|
+
* `shard-name`, `providerKind`). Done LAST so a failure in
|
|
62
|
+
* the device-readiness path (steps 3–7) short-circuits before
|
|
63
|
+
* we publish a "we successfully took ownership" telemetry
|
|
64
|
+
* record. The matching release line is emitted by
|
|
65
|
+
* `globalTeardown`.
|
|
66
|
+
*/
|
|
67
|
+
export function createGlobalSetup(opts: GlobalSetupOptions): () => Promise<void> {
|
|
68
|
+
return async function globalSetup(): Promise<void> {
|
|
69
|
+
assertBaselineUpdatesAllowed();
|
|
70
|
+
cleanArtifacts(opts.testResultsDir);
|
|
71
|
+
|
|
72
|
+
// A run through a deployment driver has no physical device to
|
|
73
|
+
// resolve, probe, or lease; each worker builds its own host.
|
|
74
|
+
if (process.env[ENV_VAR.RK_DEVICE_DRIVER_MODULE]) return;
|
|
75
|
+
|
|
76
|
+
const ip = await resolveActiveDeviceIp();
|
|
77
|
+
await persistDeviceIp(opts.testResultsDir, ip);
|
|
78
|
+
process.env[ENV_VAR.RK_DEVICE_IP] = ip;
|
|
79
|
+
|
|
80
|
+
const client = createDefaultClient({ deviceIp: ip });
|
|
81
|
+
// Gate on ECP *and* the sideload installer, not ECP alone. ECP is
|
|
82
|
+
// the first port Roku OS brings up; the installer trails it, so a
|
|
83
|
+
// single `/query/device-info` says nothing about whether the very
|
|
84
|
+
// first `plugin_install` of the run will be accepted. Paying the
|
|
85
|
+
// wait here makes the cost visible and shared, instead of dumping
|
|
86
|
+
// it on whichever suite happens to be scheduled first.
|
|
87
|
+
const info = await client.waitForDeviceFullyReady({ ecpTimeoutMs: STARTUP_ECP_TIMEOUT_MS });
|
|
88
|
+
assertSupportedVideoMode(info.videoMode);
|
|
89
|
+
const provisioning = await readLeaseProvisioning();
|
|
90
|
+
writeDeviceEnvSnapshot(opts.testResultsDir, info, provisioning);
|
|
91
|
+
logDeviceDiagnostic(info, provisioning);
|
|
92
|
+
|
|
93
|
+
await resetAndStartLogSession(client.getServerUrl(), ip);
|
|
94
|
+
|
|
95
|
+
// Telemetry must come AFTER device readiness so we never
|
|
96
|
+
// publish an acquire record for a run that is about to
|
|
97
|
+
// throw at startup (skewed wall-time + device-utilization
|
|
98
|
+
// baselines downstream).
|
|
99
|
+
const telemetry = resolveTelemetry({ deviceId: ip });
|
|
100
|
+
writeJUnitTelemetryPropertiesFile(join(opts.testResultsDir, ARTIFACT_FILE.JUNIT_CASE_PROPERTIES), telemetry);
|
|
101
|
+
emitDeviceLeaseLog(buildAcquireEvent(telemetry));
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `/logs/stop` (idempotent) + `/logs/start` so every worker SSE
|
|
107
|
+
* subscriber sees an identical starting buffer. Swallows transport
|
|
108
|
+
* errors so suites without log assertions still run.
|
|
109
|
+
*/
|
|
110
|
+
async function resetAndStartLogSession(serverUrl: string, deviceIp: string): Promise<void> {
|
|
111
|
+
const stopUrl = `${trimTrailingSlash(serverUrl)}/logs/stop`;
|
|
112
|
+
const startUrl = `${trimTrailingSlash(serverUrl)}/logs/start`;
|
|
113
|
+
const body = JSON.stringify({ device: deviceIp, port: ROKU_LOG_PORT });
|
|
114
|
+
const jsonHeaders = { "Content-Type": "application/json" };
|
|
115
|
+
try {
|
|
116
|
+
// Idempotent stop. Only warn on transport failures so a
|
|
117
|
+
// misconfigured daemon shows up in CI output.
|
|
118
|
+
await fetch(stopUrl, { method: "POST", headers: jsonHeaders, body }).catch(err => {
|
|
119
|
+
console.warn(`[device-test-client] /logs/stop failed (ignored): ${err.message}`);
|
|
120
|
+
});
|
|
121
|
+
const res = await fetch(startUrl, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: jsonHeaders,
|
|
124
|
+
body
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok && res.status !== 400) {
|
|
127
|
+
console.warn(
|
|
128
|
+
`[device-test-client] /logs/start returned ${res.status} — log assertions may not work this run.`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn(
|
|
133
|
+
`[device-test-client] rk server unreachable at ${serverUrl} (${(err as Error).message}); ` +
|
|
134
|
+
`log assertions will fail fast on first use.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* `createGlobalTeardown` — factory for the function a consumer passes
|
|
5
|
+
* to Jest's `globalTeardown` config key.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `jest-setup.ts` so the `globalTeardown` wiring lives
|
|
8
|
+
* alongside `global-setup.ts` as a mirror pair. `jest-setup.ts`
|
|
9
|
+
* re-exports `createGlobalTeardown` and `CreateGlobalTeardownOptions`
|
|
10
|
+
* for API compatibility.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ECP_PORT } from "@roku-sdk/tooling/src/device-test-helpers";
|
|
14
|
+
|
|
15
|
+
import { resolveServerUrl } from "../client/server-url";
|
|
16
|
+
import { ENV_VAR, ROKU_LOG_PORT } from "../constants";
|
|
17
|
+
import { trimTrailingSlash } from "./artifacts";
|
|
18
|
+
import { buildReleaseEvent, emitDeviceLeaseLog, resolveTelemetry } from "./telemetry";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for `createGlobalTeardown`.
|
|
22
|
+
*/
|
|
23
|
+
export interface CreateGlobalTeardownOptions {
|
|
24
|
+
/** Override the `rk server` URL (defaults to the shared resolver). */
|
|
25
|
+
serverUrl?: string;
|
|
26
|
+
/** Override the device IP (defaults to `RK_DEVICE_IP`). */
|
|
27
|
+
deviceIp?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Whether to send ECP `/keypress/Home` to the device at the end of
|
|
30
|
+
* the run so the sideloaded dev channel exits to the home screen
|
|
31
|
+
* instead of being left on the last test's scene.
|
|
32
|
+
*
|
|
33
|
+
* Defaults to `true` because leaving the channel active between
|
|
34
|
+
* runs is an almost-universal papercut: the next `buildAndDeploy`
|
|
35
|
+
* sideloads over a live channel (slightly slower install + noisier
|
|
36
|
+
* logs), the device's screen burns the last fixture until the user
|
|
37
|
+
* intervenes, and if the last test tripped an uncaught effect the
|
|
38
|
+
* channel keeps emitting log lines after the Jest process exits.
|
|
39
|
+
* Best-effort — teardown never throws on transport failure, the
|
|
40
|
+
* same contract as the `/logs/stop` POST below.
|
|
41
|
+
*
|
|
42
|
+
* Set to `false` if you explicitly want to inspect the last
|
|
43
|
+
* scene on the device after the run (e.g. manual screenshot
|
|
44
|
+
* debugging).
|
|
45
|
+
*/
|
|
46
|
+
exitToHome?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds the function passed to Jest's `globalTeardown`. Three jobs:
|
|
51
|
+
*
|
|
52
|
+
* 1. Emit the `[device-lease]` release log line (telemetry).
|
|
53
|
+
* Done FIRST so the dashboard gets a release record even if
|
|
54
|
+
* the best-effort I/O steps below stall on a half-dead
|
|
55
|
+
* device and the process is reaped externally.
|
|
56
|
+
* 2. Send ECP `/keypress/Home` to the device so the sideloaded dev
|
|
57
|
+
* channel exits to the home screen. Avoids leaving the last
|
|
58
|
+
* test's fixture on-screen between runs. Default-on; opt out
|
|
59
|
+
* via `opts.exitToHome = false`. See `CreateGlobalTeardownOptions.exitToHome`
|
|
60
|
+
* for the rationale.
|
|
61
|
+
* 3. Stop the `rk server` log session started in `createGlobalSetup`
|
|
62
|
+
* so the daemon's connection to the device is released between
|
|
63
|
+
* suites.
|
|
64
|
+
*
|
|
65
|
+
* Steps 2 and 3 are best-effort — errors are logged, not thrown.
|
|
66
|
+
* Teardown must not fail the run (Jest exits non-zero if it does,
|
|
67
|
+
* masking the real test status).
|
|
68
|
+
*
|
|
69
|
+
* Step 2 → 3 order: Home first, then `/logs/stop`. Exiting the
|
|
70
|
+
* channel while the log tail is still attached lets the
|
|
71
|
+
* `AppExitInitiate` beacon show up in the session buffer, which is
|
|
72
|
+
* occasionally useful for post-mortem debugging if a suite fails;
|
|
73
|
+
* the subsequent `/logs/stop` cleanly releases the SSE regardless.
|
|
74
|
+
*/
|
|
75
|
+
export function createGlobalTeardown(opts: CreateGlobalTeardownOptions = {}): () => Promise<void> {
|
|
76
|
+
return async function globalTeardown(): Promise<void> {
|
|
77
|
+
// Shared resolver (see `server-url.ts`) — same precedence as
|
|
78
|
+
// `RkClient` so the teardown hits the same daemon the worker
|
|
79
|
+
// talked to during the suite, including the `.roku/
|
|
80
|
+
// rk-server.json` discovery path.
|
|
81
|
+
const serverUrl = resolveServerUrl({ serverUrl: opts.serverUrl });
|
|
82
|
+
const deviceIp = opts.deviceIp ?? process.env[ENV_VAR.RK_DEVICE_IP];
|
|
83
|
+
// No `deviceIp` ⇒ globalSetup never published an acquire
|
|
84
|
+
// record (it threw before step 8) ⇒ no symmetric release
|
|
85
|
+
// record to emit. Skipping keeps the acquire/release pair
|
|
86
|
+
// invariant for downstream telemetry.
|
|
87
|
+
if (!deviceIp) return;
|
|
88
|
+
|
|
89
|
+
// Emit the release log line FIRST. Best-effort I/O below
|
|
90
|
+
// (ECP `Home`, `/logs/stop`) can stall on a half-dead
|
|
91
|
+
// device; emitting first guarantees the dashboard gets a
|
|
92
|
+
// release record even if those calls hang to their
|
|
93
|
+
// timeouts and the process is reaped externally. See
|
|
94
|
+
// `globalSetup` for the symmetric ordering rationale on
|
|
95
|
+
// the acquire side.
|
|
96
|
+
emitDeviceLeaseLog(buildReleaseEvent(resolveTelemetry({ deviceId: deviceIp })));
|
|
97
|
+
|
|
98
|
+
if (opts.exitToHome !== false) {
|
|
99
|
+
// Direct ECP — no queue, no retry. The dev channel is
|
|
100
|
+
// exiting either way; a transient network blip just means
|
|
101
|
+
// it stays active until the next run's `buildAndDeploy`
|
|
102
|
+
// replaces it. Short timeout so a dead device never stalls
|
|
103
|
+
// Jest's exit.
|
|
104
|
+
const homeUrl = `http://${deviceIp}:${ECP_PORT}/keypress/Home`;
|
|
105
|
+
try {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const timer = setTimeout(() => controller.abort(), 2_000);
|
|
108
|
+
try {
|
|
109
|
+
await fetch(homeUrl, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
signal: controller.signal
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn(`[device-test-client] globalTeardown: POST ${homeUrl} failed: ${(err as Error).message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const url = `${trimTrailingSlash(serverUrl)}/logs/stop`;
|
|
122
|
+
try {
|
|
123
|
+
await fetch(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
body: JSON.stringify({ device: deviceIp, port: ROKU_LOG_PORT })
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.warn(`[device-test-client] globalTeardown: POST ${url} failed: ${(err as Error).message}`);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|