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