@roku-sdk/device-test-client 0.0.0-stage → 2.18.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/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 +525 -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 +314 -0
- package/docs/preconditions.md +317 -0
- package/docs/rkclient.md +655 -0
- package/docs/screenshot-assertions.md +316 -0
- package/internal/constants.ts +54 -0
- package/internal/deploy.ts +60 -0
- package/internal/paths.ts +15 -0
- package/package.json +80 -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 +1656 -0
- package/src/client/deploy.ts +402 -0
- package/src/client/ecp-queue.ts +112 -0
- package/src/client/ecp-retry.ts +94 -0
- package/src/client/factory.ts +147 -0
- package/src/client/health.ts +214 -0
- package/src/client/parsers.ts +155 -0
- package/src/client/server-url.ts +137 -0
- package/src/constants.ts +471 -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 +773 -0
- package/src/index.ts +186 -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 +133 -0
- package/src/jest/global-teardown.ts +132 -0
- package/src/jest/install-hooks.ts +488 -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 +265 -0
- package/src/logs/log-session.ts +374 -0
- package/src/logs/log-stream-client.ts +296 -0
- package/src/paths.ts +65 -0
- package/src/types.ts +443 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Custom Jest environment that hooks `handleTestEvent` to capture
|
|
5
|
+
* device-side artifacts on failure and writes them to
|
|
6
|
+
* `<testResultsDir>/failures/<sanitized>/`. Used over `afterEach`
|
|
7
|
+
* because circus emits structured `test_fn_failure`/`hook_failure`
|
|
8
|
+
* events with the actual error.
|
|
9
|
+
*
|
|
10
|
+
* Capture runs EAGERLY on each failure event (not at teardown) so
|
|
11
|
+
* screenshots/DOM/logs reflect the device state at the moment of
|
|
12
|
+
* failure rather than after the next test's `beforeAll` deploys
|
|
13
|
+
* over it. Disk writes are still deferred to `teardown` (off the
|
|
14
|
+
* hot path).
|
|
15
|
+
*
|
|
16
|
+
* Globals set by the consumer's `setup.ts`:
|
|
17
|
+
* - `DEVICE_ENV_GLOBAL_KEY.CLIENT` — RkClient
|
|
18
|
+
* - `DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR` — abs path
|
|
19
|
+
* - `DEVICE_ENV_GLOBAL_KEY.CAPTURE_ENABLED` — opt-out flag
|
|
20
|
+
*
|
|
21
|
+
* All optional. Capture short-circuits when `CAPTURE_ENABLED ===
|
|
22
|
+
* false`, or when both `CAPTURE_ENABLED` and `TEST_RESULTS_DIR`
|
|
23
|
+
* are undefined (vanilla consumer that picked this env via
|
|
24
|
+
* misconfigured preset).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import { DEVICE_ENV_GLOBAL_KEY, FAILURE_RECENT_LOG_LINES } from "../constants";
|
|
30
|
+
import { TEST_RESULTS_DIR_NAME } from "../paths";
|
|
31
|
+
import type { LogLine } from "../types";
|
|
32
|
+
import { ensureFailuresDir, sanitizeTestPath, writeFailureBundle } from "./failure-artifacts";
|
|
33
|
+
|
|
34
|
+
// Runtime `require()` of `jest-environment-node` keeps the
|
|
35
|
+
// dependency out of our TS type graph and picks whichever Jest
|
|
36
|
+
// version is hosting the worker (29 or 30).
|
|
37
|
+
|
|
38
|
+
interface NodeEnvironmentConstructor {
|
|
39
|
+
new (config: unknown, context?: unknown): NodeEnvironmentLike;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Base class shape; `any` to avoid coupling to Jest 29 vs 30. */
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
type NodeEnvironmentLike = any;
|
|
45
|
+
|
|
46
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
47
|
+
const NodeEnvModule = require("jest-environment-node");
|
|
48
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
49
|
+
|
|
50
|
+
// Both Jest 29 and 30 export the class as `TestEnvironment`.
|
|
51
|
+
const NodeEnvironment: NodeEnvironmentConstructor = NodeEnvModule.TestEnvironment;
|
|
52
|
+
|
|
53
|
+
// Local circus-event shapes (stable across Jest 29/30) so we don't
|
|
54
|
+
// need `@jest/types` as a dep.
|
|
55
|
+
|
|
56
|
+
interface CircusNamedBlock {
|
|
57
|
+
name: string;
|
|
58
|
+
parent?: CircusNamedBlock;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CircusTestEntry extends CircusNamedBlock {
|
|
62
|
+
errors?: unknown[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface CircusHook extends CircusNamedBlock {
|
|
66
|
+
type: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TestFnFailureEvent {
|
|
70
|
+
name: "test_fn_failure";
|
|
71
|
+
test: CircusTestEntry;
|
|
72
|
+
error?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface HookFailureEvent {
|
|
76
|
+
name: "hook_failure";
|
|
77
|
+
hook: CircusHook;
|
|
78
|
+
test?: CircusTestEntry;
|
|
79
|
+
error?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface TeardownEvent {
|
|
83
|
+
name: "teardown";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type KnownEvent = TestFnFailureEvent | HookFailureEvent | TeardownEvent;
|
|
87
|
+
|
|
88
|
+
// Structural client shape — keeps the environment decoupled from
|
|
89
|
+
// the full client module graph so unit tests can inject fakes.
|
|
90
|
+
export interface FailureCaptureClient {
|
|
91
|
+
captureScreenshot(): Promise<Buffer>;
|
|
92
|
+
queryAppUi(): Promise<unknown>;
|
|
93
|
+
recentLogLines(n: number): ReadonlyArray<{ seq: number; line: LogLine }>;
|
|
94
|
+
/** Optional; older clients/fakes may omit. Probed before calling. */
|
|
95
|
+
getLogStreamHealth?(): Promise<Record<string, unknown>>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface PendingFailure {
|
|
99
|
+
/** describe ancestors + it name (root excluded). */
|
|
100
|
+
testPath: string[];
|
|
101
|
+
error: unknown;
|
|
102
|
+
/** Device-side artifacts captured at failure event time (best-effort). */
|
|
103
|
+
screenshot: Buffer | null;
|
|
104
|
+
domTree: unknown;
|
|
105
|
+
recentLogs: ReadonlyArray<{ seq: number; line: LogLine }> | null;
|
|
106
|
+
streamHealth: Record<string, unknown> | null;
|
|
107
|
+
/** Capture-time warnings; merged with write-time on flush. */
|
|
108
|
+
captureWarnings: Array<{ artifact: string; cause: Error }>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subclass of `jest-environment-node`'s `TestEnvironment`. Implements
|
|
113
|
+
* `handleTestEvent` to buffer failures and flush them on `teardown`.
|
|
114
|
+
*/
|
|
115
|
+
export default class DeviceTestEnvironment extends NodeEnvironment {
|
|
116
|
+
/** Failure buffer — flushed once on the single `teardown` event. */
|
|
117
|
+
private pendingFailures: PendingFailure[] = [];
|
|
118
|
+
|
|
119
|
+
async handleTestEvent(event: unknown, _state: unknown): Promise<void> {
|
|
120
|
+
const typed = event as KnownEvent;
|
|
121
|
+
if (typed.name === "test_fn_failure") {
|
|
122
|
+
if (!this.shouldCapture()) return;
|
|
123
|
+
await this.recordFailure(buildTestPath(typed.test), pickError(typed.test.errors, typed.error));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (typed.name === "hook_failure") {
|
|
127
|
+
if (!this.shouldCapture()) return;
|
|
128
|
+
const testPath = typed.test ? buildTestPath(typed.test) : buildHookPath(typed.hook);
|
|
129
|
+
await this.recordFailure(testPath, typed.error ?? new Error(`${typed.hook.type} failed`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (typed.name === "teardown") {
|
|
133
|
+
await this.flushPendingFailures();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Capture gate. `CAPTURE_ENABLED === false` → off; both
|
|
139
|
+
* `CAPTURE_ENABLED` and `TEST_RESULTS_DIR` undefined → off
|
|
140
|
+
* (vanilla consumer didn't opt in). Defensive overlap with the
|
|
141
|
+
* flush guard in case globals mutate between events.
|
|
142
|
+
*/
|
|
143
|
+
private shouldCapture(): boolean {
|
|
144
|
+
const envGlobal = this.global as Record<string, unknown>;
|
|
145
|
+
const captureEnabled = envGlobal[DEVICE_ENV_GLOBAL_KEY.CAPTURE_ENABLED];
|
|
146
|
+
if (captureEnabled === false) return false;
|
|
147
|
+
if (captureEnabled === undefined && envGlobal[DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR] === undefined) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Capture device-side artifacts NOW (eager). Runs inside Jest's
|
|
155
|
+
* awaited `handleTestEvent`, blocking the next `beforeEach` until
|
|
156
|
+
* the snapshot is in hand. Each call wrapped in `captureArtifact`
|
|
157
|
+
* so one throw doesn't abort the others.
|
|
158
|
+
*/
|
|
159
|
+
private async recordFailure(testPath: string[], error: unknown): Promise<void> {
|
|
160
|
+
const envGlobal = this.global as Record<string, unknown>;
|
|
161
|
+
const client = envGlobal[DEVICE_ENV_GLOBAL_KEY.CLIENT] as FailureCaptureClient | undefined;
|
|
162
|
+
|
|
163
|
+
const captureWarnings: Array<{ artifact: string; cause: Error }> = [];
|
|
164
|
+
let screenshot: Buffer | null = null;
|
|
165
|
+
let domTree: unknown = null;
|
|
166
|
+
let recentLogs: ReadonlyArray<{ seq: number; line: LogLine }> | null = null;
|
|
167
|
+
let streamHealth: Record<string, unknown> | null = null;
|
|
168
|
+
|
|
169
|
+
if (client) {
|
|
170
|
+
screenshot = await captureArtifact("screenshot", () => client.captureScreenshot(), captureWarnings);
|
|
171
|
+
domTree = await captureArtifact("dom-tree", () => client.queryAppUi(), captureWarnings);
|
|
172
|
+
recentLogs =
|
|
173
|
+
(await captureArtifact(
|
|
174
|
+
"recent-logs",
|
|
175
|
+
async () => client.recentLogLines(FAILURE_RECENT_LOG_LINES),
|
|
176
|
+
captureWarnings
|
|
177
|
+
)) ?? [];
|
|
178
|
+
if (typeof client.getLogStreamHealth === "function") {
|
|
179
|
+
streamHealth = await captureArtifact(
|
|
180
|
+
"stream-health",
|
|
181
|
+
async () => {
|
|
182
|
+
// Re-narrow locally; the closure doesn't see
|
|
183
|
+
// the typeof guard above.
|
|
184
|
+
const fn = client.getLogStreamHealth!;
|
|
185
|
+
return await fn.call(client);
|
|
186
|
+
},
|
|
187
|
+
captureWarnings
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Surface the one-line stream-health summary so CI stdout
|
|
193
|
+
// shows gap context inline with the failure without
|
|
194
|
+
// requiring the reviewer to fetch the on-disk bundle.
|
|
195
|
+
// Routed through `console.warn` (not error.message/.stack
|
|
196
|
+
// mutation) because (a) Jest's reporter pipes our console
|
|
197
|
+
// calls right next to the failure, so visibility is
|
|
198
|
+
// preserved, and (b) mutating foreign Error objects
|
|
199
|
+
// throws on frozen errors and breaks custom matchers that
|
|
200
|
+
// pin on `expect(err.message).toMatch(...)`. The bundle
|
|
201
|
+
// already persists `streamHealth` separately for offline
|
|
202
|
+
// analysis, so this is purely an inline-visibility hook.
|
|
203
|
+
emitStreamHealthSummary(testPath, streamHealth);
|
|
204
|
+
|
|
205
|
+
this.pendingFailures.push({
|
|
206
|
+
testPath,
|
|
207
|
+
error,
|
|
208
|
+
screenshot,
|
|
209
|
+
domTree,
|
|
210
|
+
recentLogs,
|
|
211
|
+
streamHealth,
|
|
212
|
+
captureWarnings
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Drain `pendingFailures` and write one bundle per failure. All
|
|
218
|
+
* work is best-effort and must not propagate out of `teardown`.
|
|
219
|
+
* Emits at most ONE warning per bundle covering every capture +
|
|
220
|
+
* write failure for that bundle.
|
|
221
|
+
*/
|
|
222
|
+
private async flushPendingFailures(): Promise<void> {
|
|
223
|
+
if (this.pendingFailures.length === 0) return;
|
|
224
|
+
const envGlobal = this.global as Record<string, unknown>;
|
|
225
|
+
|
|
226
|
+
// Defensive re-check: a late `CAPTURE_ENABLED = false`
|
|
227
|
+
// write still suppresses on-disk output.
|
|
228
|
+
if (!this.shouldCapture()) {
|
|
229
|
+
this.pendingFailures = [];
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const resultsDir = resolveTestResultsDir(envGlobal);
|
|
234
|
+
let failuresDir: string;
|
|
235
|
+
try {
|
|
236
|
+
failuresDir = ensureFailuresDir(resultsDir);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.warn(
|
|
239
|
+
`[device-test-client] could not create failures dir at "${resultsDir}": ` +
|
|
240
|
+
`${(err as Error).message}. Failure diagnostics will be skipped.`,
|
|
241
|
+
err
|
|
242
|
+
);
|
|
243
|
+
this.pendingFailures = [];
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const failure of this.pendingFailures) {
|
|
248
|
+
const sanitized = sanitizeTestPath(failure.testPath);
|
|
249
|
+
const bundleDir = join(failuresDir, sanitized);
|
|
250
|
+
|
|
251
|
+
// Capture-time + write-time warnings → ONE warn per bundle.
|
|
252
|
+
const warnings: Array<{ artifact: string; cause: Error }> = [...failure.captureWarnings];
|
|
253
|
+
|
|
254
|
+
const result = writeFailureBundle(bundleDir, {
|
|
255
|
+
screenshot: failure.screenshot,
|
|
256
|
+
domTree: failure.domTree,
|
|
257
|
+
recentLogs: failure.recentLogs,
|
|
258
|
+
streamHealth: failure.streamHealth,
|
|
259
|
+
error: failure.error
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
for (const f of result.failed) {
|
|
263
|
+
warnings.push(f);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (warnings.length > 0) {
|
|
267
|
+
emitBundleWarning(sanitized, warnings);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// One-line pointer so reviewers find the bundle without
|
|
271
|
+
// knowing the `test-results/failures/...` convention.
|
|
272
|
+
// Skipped when nothing landed (mkdir failure).
|
|
273
|
+
if (result.written.length > 0) {
|
|
274
|
+
console.log(
|
|
275
|
+
`[device-test-client] failure bundle written: ${bundleDir} ` + `(${result.written.join(", ")})`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.pendingFailures = [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Await `fn`; on throw, push `{artifact, cause}` and return null so
|
|
286
|
+
* the bundle writer still gets a partial input. Caller emits one
|
|
287
|
+
* consolidated warning per bundle.
|
|
288
|
+
*/
|
|
289
|
+
async function captureArtifact<T>(
|
|
290
|
+
artifact: string,
|
|
291
|
+
fn: () => Promise<T>,
|
|
292
|
+
warnings: Array<{ artifact: string; cause: Error }>
|
|
293
|
+
): Promise<T | null> {
|
|
294
|
+
try {
|
|
295
|
+
return await fn();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
warnings.push({ artifact, cause: toError(err) });
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One warn per bundle. Pass each underlying `Error` separately so
|
|
304
|
+
* Node's console printer renders the full stack.
|
|
305
|
+
*/
|
|
306
|
+
function emitBundleWarning(sanitized: string, warnings: ReadonlyArray<{ artifact: string; cause: Error }>): void {
|
|
307
|
+
const summary = warnings.map(w => `${w.artifact}=${w.cause.message}`).join("; ");
|
|
308
|
+
console.warn(`[device-test-client] bundle "${sanitized}" had warnings: ${summary}`, ...warnings.map(w => w.cause));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function toError(err: unknown): Error {
|
|
312
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Emit a one-line stream-health summary via `console.warn` so
|
|
317
|
+
* Jest's reporter renders it inline with the failure. Replaces an
|
|
318
|
+
* earlier in-place mutation of `error.message`/`error.stack`,
|
|
319
|
+
* which threw on frozen errors and broke custom matchers that
|
|
320
|
+
* pinned on `expect(err.message).toMatch(...)`.
|
|
321
|
+
*
|
|
322
|
+
* Skipped on trivial snapshots so healthy runs don't gain noise.
|
|
323
|
+
* `testPath` is included in the prefix so a multi-failure run
|
|
324
|
+
* still attributes each summary to the right test.
|
|
325
|
+
*/
|
|
326
|
+
function emitStreamHealthSummary(testPath: string[], health: Record<string, unknown> | null): void {
|
|
327
|
+
if (!health) return;
|
|
328
|
+
const summary = formatStreamHealthSummary(health);
|
|
329
|
+
if (!summary) return;
|
|
330
|
+
const label = testPath.length > 0 ? testPath.join(" › ") : "<unknown test>";
|
|
331
|
+
console.warn(`[device-test-client] [log-stream-health] ${label}: ${summary}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* One-line `LogStreamHealth` summary; `""` for trivially-clean
|
|
336
|
+
* snapshots so healthy runs stay terse. Reads fields defensively
|
|
337
|
+
* to avoid an env→types import edge.
|
|
338
|
+
*/
|
|
339
|
+
function formatStreamHealthSummary(health: Record<string, unknown>): string {
|
|
340
|
+
const gapCount = numberFrom(health.gapCount, 0);
|
|
341
|
+
const totalGapSeqSpan = numberFrom(health.totalGapSeqSpan, 0);
|
|
342
|
+
const openGap = health.openGap === true;
|
|
343
|
+
const server = isObject(health.serverMetrics) ? health.serverMetrics : null;
|
|
344
|
+
const reconnectCount = server ? numberFrom(server.reconnectCount, 0) : 0;
|
|
345
|
+
const totalDowntime = server ? numberFrom(server.totalDowntime, 0) : 0;
|
|
346
|
+
const trivial = gapCount === 0 && reconnectCount === 0 && !openGap;
|
|
347
|
+
if (trivial) return "";
|
|
348
|
+
const parts: string[] = [];
|
|
349
|
+
parts.push(`client gaps=${gapCount} (span=${totalGapSeqSpan} seqs)`);
|
|
350
|
+
if (openGap) parts.push(`open-gap=true`);
|
|
351
|
+
if (server) {
|
|
352
|
+
parts.push(`server reconnects=${reconnectCount} downtime=${Math.round(totalDowntime)}ms`);
|
|
353
|
+
} else if (health.serverMetrics === null) {
|
|
354
|
+
parts.push(`server metrics unavailable (rk server unreachable)`);
|
|
355
|
+
}
|
|
356
|
+
return parts.join("; ");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function numberFrom(v: unknown, fallback: number): number {
|
|
360
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
364
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Build `[...describeAncestors, testName]`; drops ROOT_DESCRIBE_BLOCK. */
|
|
368
|
+
function buildTestPath(test: CircusTestEntry): string[] {
|
|
369
|
+
const parts: string[] = [];
|
|
370
|
+
const ancestors: string[] = [];
|
|
371
|
+
let cursor: CircusNamedBlock | undefined = test.parent;
|
|
372
|
+
while (cursor) {
|
|
373
|
+
ancestors.unshift(cursor.name);
|
|
374
|
+
cursor = cursor.parent;
|
|
375
|
+
}
|
|
376
|
+
for (const name of ancestors) {
|
|
377
|
+
if (name === "ROOT_DESCRIBE_BLOCK") continue;
|
|
378
|
+
parts.push(name);
|
|
379
|
+
}
|
|
380
|
+
parts.push(test.name);
|
|
381
|
+
return parts;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Path for a hook failure with no test; leaf = `hook-<type>`. */
|
|
385
|
+
function buildHookPath(hook: CircusHook): string[] {
|
|
386
|
+
const parts: string[] = [];
|
|
387
|
+
const ancestors: string[] = [];
|
|
388
|
+
let cursor: CircusNamedBlock | undefined = hook.parent;
|
|
389
|
+
while (cursor) {
|
|
390
|
+
ancestors.unshift(cursor.name);
|
|
391
|
+
cursor = cursor.parent;
|
|
392
|
+
}
|
|
393
|
+
for (const name of ancestors) {
|
|
394
|
+
if (name === "ROOT_DESCRIBE_BLOCK") continue;
|
|
395
|
+
parts.push(name);
|
|
396
|
+
}
|
|
397
|
+
parts.push(`hook-${hook.type}`);
|
|
398
|
+
return parts;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Pick the most actionable error from a circus entry. Last entry
|
|
403
|
+
* wins (follow-on errors are less interesting than the original).
|
|
404
|
+
*/
|
|
405
|
+
function pickError(errors: unknown[] | undefined, fallback: unknown): unknown {
|
|
406
|
+
if (errors && errors.length > 0) {
|
|
407
|
+
const last = errors[errors.length - 1];
|
|
408
|
+
if (Array.isArray(last) && last.length > 0) return last[0];
|
|
409
|
+
return last;
|
|
410
|
+
}
|
|
411
|
+
return fallback ?? new Error("unknown test failure");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function resolveTestResultsDir(envGlobal: Record<string, unknown>): string {
|
|
415
|
+
const fromGlobal = envGlobal[DEVICE_ENV_GLOBAL_KEY.TEST_RESULTS_DIR];
|
|
416
|
+
if (typeof fromGlobal === "string" && fromGlobal.length > 0) {
|
|
417
|
+
return fromGlobal;
|
|
418
|
+
}
|
|
419
|
+
return join(process.cwd(), TEST_RESULTS_DIR_NAME);
|
|
420
|
+
}
|
|
@@ -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
|
+
}
|