everything-dev 0.1.3 → 0.1.4

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.
@@ -0,0 +1,257 @@
1
+ import { Effect } from "effect";
2
+ import { BrowserLaunchFailed, BrowserMetricsFailed } from "./errors";
3
+ import type { BrowserMetrics } from "./types";
4
+
5
+ type Browser = {
6
+ newContext: (options?: Record<string, unknown>) => Promise<BrowserContext>;
7
+ close: () => Promise<void>;
8
+ };
9
+
10
+ type BrowserContext = {
11
+ newPage: () => Promise<Page>;
12
+ close: () => Promise<void>;
13
+ on: (event: string, handler: (page: Page) => void) => void;
14
+ waitForEvent: (event: string, options?: { timeout?: number }) => Promise<Page>;
15
+ };
16
+
17
+ type Page = {
18
+ goto: (url: string, options?: Record<string, unknown>) => Promise<void>;
19
+ click: (selector: string, options?: Record<string, unknown>) => Promise<void>;
20
+ fill: (selector: string, value: string) => Promise<void>;
21
+ waitForSelector: (selector: string, options?: { timeout?: number }) => Promise<void>;
22
+ waitForLoadState: (state?: string) => Promise<void>;
23
+ waitForTimeout: (ms: number) => Promise<void>;
24
+ url: () => string;
25
+ title: () => Promise<string>;
26
+ close: () => Promise<void>;
27
+ metrics: () => Promise<Record<string, number>>;
28
+ evaluate: <T>(fn: () => T) => Promise<T>;
29
+ };
30
+
31
+ type CDPSession = {
32
+ send: (method: string) => Promise<Record<string, unknown>>;
33
+ detach: () => Promise<void>;
34
+ };
35
+
36
+ interface PlaywrightModule {
37
+ chromium: {
38
+ launch: (options?: { headless?: boolean; devtools?: boolean }) => Promise<Browser>;
39
+ };
40
+ }
41
+
42
+ let playwrightModule: PlaywrightModule | null = null;
43
+
44
+ const loadPlaywright = async (): Promise<PlaywrightModule> => {
45
+ if (playwrightModule) return playwrightModule;
46
+
47
+ try {
48
+ // @ts-expect-error - playwright may not be installed
49
+ const pw = await import("playwright");
50
+ playwrightModule = pw as PlaywrightModule;
51
+ return playwrightModule;
52
+ } catch {
53
+ throw new Error(
54
+ "Playwright is not installed. Run: bun add -d playwright"
55
+ );
56
+ }
57
+ };
58
+
59
+ export interface BrowserHandle {
60
+ browser: Browser;
61
+ context: BrowserContext;
62
+ page: Page;
63
+ close: () => Promise<void>;
64
+ }
65
+
66
+ export const launchBrowser = (
67
+ headless = true
68
+ ): Effect.Effect<BrowserHandle, BrowserLaunchFailed> =>
69
+ Effect.gen(function* () {
70
+ yield* Effect.logInfo(`Launching browser (headless: ${headless})`);
71
+
72
+ const pw = yield* Effect.tryPromise({
73
+ try: () => loadPlaywright(),
74
+ catch: (e) => new BrowserLaunchFailed({
75
+ reason: String(e),
76
+ headless,
77
+ }),
78
+ });
79
+
80
+ const browser = yield* Effect.tryPromise({
81
+ try: () => pw.chromium.launch({
82
+ headless,
83
+ devtools: !headless,
84
+ }),
85
+ catch: (e) => new BrowserLaunchFailed({
86
+ reason: `Failed to launch chromium: ${e}`,
87
+ headless,
88
+ }),
89
+ });
90
+
91
+ const context = yield* Effect.tryPromise({
92
+ try: () => browser.newContext({
93
+ viewport: { width: 1280, height: 720 },
94
+ userAgent: "SessionRecorder/1.0",
95
+ }),
96
+ catch: (e) => new BrowserLaunchFailed({
97
+ reason: `Failed to create context: ${e}`,
98
+ headless,
99
+ }),
100
+ });
101
+
102
+ const page = yield* Effect.tryPromise({
103
+ try: () => context.newPage(),
104
+ catch: (e) => new BrowserLaunchFailed({
105
+ reason: `Failed to create page: ${e}`,
106
+ headless,
107
+ }),
108
+ });
109
+
110
+ yield* Effect.logInfo("Browser launched successfully");
111
+
112
+ return {
113
+ browser,
114
+ context,
115
+ page,
116
+ close: async () => {
117
+ await context.close();
118
+ await browser.close();
119
+ },
120
+ };
121
+ });
122
+
123
+ export const closeBrowser = (
124
+ handle: BrowserHandle
125
+ ): Effect.Effect<void> =>
126
+ Effect.gen(function* () {
127
+ yield* Effect.logInfo("Closing browser");
128
+ yield* Effect.promise(() => handle.close());
129
+ yield* Effect.logInfo("Browser closed");
130
+ });
131
+
132
+ export const getBrowserMetrics = (
133
+ page: Page
134
+ ): Effect.Effect<BrowserMetrics, BrowserMetricsFailed> =>
135
+ Effect.gen(function* () {
136
+ yield* Effect.logDebug("Collecting browser metrics");
137
+
138
+ const metrics = yield* Effect.tryPromise({
139
+ try: async () => {
140
+ const pageMetrics = await page.metrics();
141
+
142
+ const memoryInfo = await page.evaluate(() => {
143
+ const perf = (performance as Performance & {
144
+ memory?: {
145
+ usedJSHeapSize: number;
146
+ totalJSHeapSize: number;
147
+ };
148
+ });
149
+ return perf.memory ? {
150
+ jsHeapUsedSize: perf.memory.usedJSHeapSize,
151
+ jsHeapTotalSize: perf.memory.totalJSHeapSize,
152
+ } : {
153
+ jsHeapUsedSize: 0,
154
+ jsHeapTotalSize: 0,
155
+ };
156
+ });
157
+
158
+ return {
159
+ jsHeapUsedSize: memoryInfo.jsHeapUsedSize,
160
+ jsHeapTotalSize: memoryInfo.jsHeapTotalSize,
161
+ documents: pageMetrics.Documents ?? 0,
162
+ frames: pageMetrics.Frames ?? 0,
163
+ jsEventListeners: pageMetrics.JSEventListeners ?? 0,
164
+ nodes: pageMetrics.Nodes ?? 0,
165
+ layoutCount: pageMetrics.LayoutCount ?? 0,
166
+ recalcStyleCount: pageMetrics.RecalcStyleCount ?? 0,
167
+ scriptDuration: pageMetrics.ScriptDuration ?? 0,
168
+ taskDuration: pageMetrics.TaskDuration ?? 0,
169
+ };
170
+ },
171
+ catch: (e) => new BrowserMetricsFailed({
172
+ reason: String(e),
173
+ }),
174
+ });
175
+
176
+ yield* Effect.logDebug(
177
+ `JS Heap: ${(metrics.jsHeapUsedSize / 1024 / 1024).toFixed(1)}MB`
178
+ );
179
+
180
+ return metrics;
181
+ });
182
+
183
+ export const navigateTo = (
184
+ page: Page,
185
+ url: string
186
+ ): Effect.Effect<void> =>
187
+ Effect.gen(function* () {
188
+ yield* Effect.logInfo(`Navigating to ${url}`);
189
+ yield* Effect.promise(() => page.goto(url, { waitUntil: "networkidle" }));
190
+ yield* Effect.logInfo(`Loaded: ${page.url()}`);
191
+ });
192
+
193
+ export const clickElement = (
194
+ page: Page,
195
+ selector: string
196
+ ): Effect.Effect<void> =>
197
+ Effect.gen(function* () {
198
+ yield* Effect.logDebug(`Clicking: ${selector}`);
199
+ yield* Effect.promise(() => page.click(selector));
200
+ });
201
+
202
+ export const waitForPopup = (
203
+ context: BrowserContext,
204
+ timeoutMs = 30000
205
+ ): Effect.Effect<Page> =>
206
+ Effect.gen(function* () {
207
+ yield* Effect.logInfo("Waiting for popup window");
208
+
209
+ const popup = yield* Effect.promise(() =>
210
+ context.waitForEvent("page", { timeout: timeoutMs })
211
+ );
212
+
213
+ yield* Effect.promise(() => popup.waitForLoadState("domcontentloaded"));
214
+ yield* Effect.logInfo(`Popup opened: ${popup.url()}`);
215
+
216
+ return popup;
217
+ });
218
+
219
+ export const closePopup = (
220
+ popup: Page
221
+ ): Effect.Effect<void> =>
222
+ Effect.gen(function* () {
223
+ yield* Effect.logInfo("Closing popup");
224
+ yield* Effect.promise(() => popup.close());
225
+ });
226
+
227
+ export const waitForSelector = (
228
+ page: Page,
229
+ selector: string,
230
+ timeoutMs = 10000
231
+ ): Effect.Effect<void> =>
232
+ Effect.gen(function* () {
233
+ yield* Effect.logDebug(`Waiting for selector: ${selector}`);
234
+ yield* Effect.promise(() => page.waitForSelector(selector, { timeout: timeoutMs }));
235
+ });
236
+
237
+ export const fillInput = (
238
+ page: Page,
239
+ selector: string,
240
+ value: string
241
+ ): Effect.Effect<void> =>
242
+ Effect.gen(function* () {
243
+ yield* Effect.logDebug(`Filling input: ${selector}`);
244
+ yield* Effect.promise(() => page.fill(selector, value));
245
+ });
246
+
247
+ export const getPageInfo = (
248
+ page: Page
249
+ ): Effect.Effect<{ url: string; title: string }> =>
250
+ Effect.gen(function* () {
251
+ const url = page.url();
252
+ const title = yield* Effect.promise(() => page.title());
253
+ return { url, title };
254
+ });
255
+
256
+ export const sleep = (ms: number): Effect.Effect<void> =>
257
+ Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -0,0 +1,353 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { Effect } from "effect";
3
+ import { diffSnapshots, hasLeaks } from "../resource-monitor";
4
+ import { ExportFailed } from "./errors";
5
+ import type {
6
+ SessionConfig,
7
+ SessionEvent,
8
+ SessionReport,
9
+ SessionSummary,
10
+ } from "./types";
11
+
12
+ export const generateSummary = (
13
+ events: SessionEvent[],
14
+ config: SessionConfig
15
+ ): SessionSummary => {
16
+ if (events.length === 0) {
17
+ return {
18
+ totalMemoryDeltaMb: 0,
19
+ peakMemoryMb: 0,
20
+ averageMemoryMb: 0,
21
+ processesSpawned: 0,
22
+ processesKilled: 0,
23
+ orphanedProcesses: 0,
24
+ portsUsed: config.ports,
25
+ portsLeaked: 0,
26
+ hasLeaks: false,
27
+ eventCount: 0,
28
+ duration: 0,
29
+ };
30
+ }
31
+
32
+ const baselineEvent = events.find((e) => e.type === "baseline");
33
+ const lastEvent = events[events.length - 1];
34
+
35
+ const memoryValues = events
36
+ .map((e) => e.snapshot.memory.processRss / 1024 / 1024)
37
+ .filter((v) => v > 0);
38
+
39
+ const peakMemoryMb = Math.max(...memoryValues, 0);
40
+ const averageMemoryMb = memoryValues.length > 0
41
+ ? memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length
42
+ : 0;
43
+
44
+ const baselineMemory = baselineEvent?.snapshot.memory.processRss ?? 0;
45
+ const finalMemory = lastEvent.snapshot.memory.processRss;
46
+ const totalMemoryDeltaMb = (finalMemory - baselineMemory) / 1024 / 1024;
47
+
48
+ const allProcessPids = new Set<number>();
49
+ const finalProcessPids = new Set<number>();
50
+
51
+ for (const event of events) {
52
+ for (const proc of event.snapshot.processes) {
53
+ allProcessPids.add(proc.pid);
54
+ }
55
+ }
56
+
57
+ for (const proc of lastEvent.snapshot.processes) {
58
+ finalProcessPids.add(proc.pid);
59
+ }
60
+
61
+ const processesSpawned = allProcessPids.size;
62
+ const processesKilled = allProcessPids.size - finalProcessPids.size;
63
+
64
+ let orphanedProcesses = 0;
65
+ let portsLeaked = 0;
66
+ let leaksDetected = false;
67
+
68
+ if (baselineEvent) {
69
+ const diff = diffSnapshots(baselineEvent.snapshot, lastEvent.snapshot);
70
+ orphanedProcesses = diff.orphanedProcesses.length;
71
+ portsLeaked = diff.stillBoundPorts.length;
72
+ leaksDetected = hasLeaks(diff);
73
+ }
74
+
75
+ const browserMetricsEvents = events.filter((e) => e.browserMetrics);
76
+ let browserMetricsSummary: SessionSummary["browserMetricsSummary"];
77
+
78
+ if (browserMetricsEvents.length > 0) {
79
+ const jsHeapValues = browserMetricsEvents.map(
80
+ (e) => e.browserMetrics!.jsHeapUsedSize / 1024 / 1024
81
+ );
82
+ const layoutCounts = browserMetricsEvents.map(
83
+ (e) => e.browserMetrics!.layoutCount
84
+ );
85
+ const scriptDurations = browserMetricsEvents.map(
86
+ (e) => e.browserMetrics!.scriptDuration
87
+ );
88
+
89
+ browserMetricsSummary = {
90
+ peakJsHeapMb: Math.max(...jsHeapValues),
91
+ averageJsHeapMb: jsHeapValues.reduce((a, b) => a + b, 0) / jsHeapValues.length,
92
+ totalLayoutCount: Math.max(...layoutCounts),
93
+ totalScriptDuration: scriptDurations.reduce((a, b) => a + b, 0),
94
+ };
95
+ }
96
+
97
+ const duration = lastEvent.timestamp - (baselineEvent?.timestamp ?? events[0].timestamp);
98
+
99
+ return {
100
+ totalMemoryDeltaMb,
101
+ peakMemoryMb,
102
+ averageMemoryMb,
103
+ processesSpawned,
104
+ processesKilled,
105
+ orphanedProcesses,
106
+ portsUsed: config.ports,
107
+ portsLeaked,
108
+ hasLeaks: leaksDetected,
109
+ eventCount: events.length,
110
+ duration,
111
+ browserMetricsSummary,
112
+ };
113
+ };
114
+
115
+ export const generateReport = (
116
+ sessionId: string,
117
+ config: SessionConfig,
118
+ events: SessionEvent[],
119
+ startTime: number,
120
+ endTime: number
121
+ ): SessionReport => {
122
+ const summary = generateSummary(events, config);
123
+
124
+ return {
125
+ sessionId,
126
+ config,
127
+ startTime,
128
+ endTime,
129
+ events,
130
+ summary,
131
+ platform: process.platform,
132
+ nodeVersion: process.version,
133
+ };
134
+ };
135
+
136
+ export const exportJSON = (
137
+ report: SessionReport,
138
+ filepath: string
139
+ ): Effect.Effect<void, ExportFailed> =>
140
+ Effect.gen(function* () {
141
+ yield* Effect.logInfo(`Exporting session report to ${filepath}`);
142
+
143
+ yield* Effect.tryPromise({
144
+ try: () => writeFile(filepath, JSON.stringify(report, null, 2)),
145
+ catch: (e) => new ExportFailed({
146
+ path: filepath,
147
+ reason: String(e),
148
+ }),
149
+ });
150
+
151
+ yield* Effect.logInfo(`Report exported: ${report.events.length} events, ${report.summary.duration}ms duration`);
152
+ });
153
+
154
+ export const formatReportSummary = (report: SessionReport): string => {
155
+ const lines: string[] = [];
156
+ const { summary } = report;
157
+
158
+ lines.push("═".repeat(60));
159
+ lines.push(" SESSION REPORT SUMMARY");
160
+ lines.push("═".repeat(60));
161
+ lines.push("");
162
+ lines.push(` Session ID: ${report.sessionId}`);
163
+ lines.push(` Duration: ${(summary.duration / 1000).toFixed(1)}s`);
164
+ lines.push(` Events: ${summary.eventCount}`);
165
+ lines.push(` Platform: ${report.platform}`);
166
+ lines.push("");
167
+ lines.push("─".repeat(60));
168
+ lines.push(" MEMORY");
169
+ lines.push("─".repeat(60));
170
+ lines.push(` Peak: ${summary.peakMemoryMb.toFixed(1)} MB`);
171
+ lines.push(` Average: ${summary.averageMemoryMb.toFixed(1)} MB`);
172
+ lines.push(` Delta: ${summary.totalMemoryDeltaMb >= 0 ? "+" : ""}${summary.totalMemoryDeltaMb.toFixed(1)} MB`);
173
+ lines.push("");
174
+ lines.push("─".repeat(60));
175
+ lines.push(" PROCESSES");
176
+ lines.push("─".repeat(60));
177
+ lines.push(` Spawned: ${summary.processesSpawned}`);
178
+ lines.push(` Killed: ${summary.processesKilled}`);
179
+ lines.push(` Orphaned: ${summary.orphanedProcesses}`);
180
+ lines.push("");
181
+ lines.push("─".repeat(60));
182
+ lines.push(" PORTS");
183
+ lines.push("─".repeat(60));
184
+ lines.push(` Monitored: ${summary.portsUsed.join(", ")}`);
185
+ lines.push(` Leaked: ${summary.portsLeaked}`);
186
+ lines.push("");
187
+
188
+ if (summary.browserMetricsSummary) {
189
+ lines.push("─".repeat(60));
190
+ lines.push(" BROWSER METRICS");
191
+ lines.push("─".repeat(60));
192
+ lines.push(` Peak JS Heap: ${summary.browserMetricsSummary.peakJsHeapMb.toFixed(1)} MB`);
193
+ lines.push(` Avg JS Heap: ${summary.browserMetricsSummary.averageJsHeapMb.toFixed(1)} MB`);
194
+ lines.push(` Layout Count: ${summary.browserMetricsSummary.totalLayoutCount}`);
195
+ lines.push(` Script Time: ${summary.browserMetricsSummary.totalScriptDuration.toFixed(2)}s`);
196
+ lines.push("");
197
+ }
198
+
199
+ lines.push("═".repeat(60));
200
+ if (summary.hasLeaks) {
201
+ lines.push(" ❌ RESOURCE LEAKS DETECTED");
202
+ if (summary.orphanedProcesses > 0) {
203
+ lines.push(` - ${summary.orphanedProcesses} orphaned process(es)`);
204
+ }
205
+ if (summary.portsLeaked > 0) {
206
+ lines.push(` - ${summary.portsLeaked} port(s) still bound`);
207
+ }
208
+ } else {
209
+ lines.push(" ✅ NO RESOURCE LEAKS");
210
+ }
211
+ lines.push("═".repeat(60));
212
+
213
+ return lines.join("\n");
214
+ };
215
+
216
+ export const formatEventTimeline = (events: SessionEvent[]): string => {
217
+ const lines: string[] = [];
218
+
219
+ lines.push("EVENT TIMELINE");
220
+ lines.push("─".repeat(80));
221
+
222
+ const baseTime = events[0]?.timestamp ?? 0;
223
+
224
+ for (const event of events) {
225
+ const elapsed = ((event.timestamp - baseTime) / 1000).toFixed(2).padStart(8);
226
+ const type = event.type.padEnd(12);
227
+ const memory = (event.snapshot.memory.processRss / 1024 / 1024).toFixed(1).padStart(6);
228
+
229
+ lines.push(` ${elapsed}s │ ${type} │ ${memory}MB │ ${event.label}`);
230
+ }
231
+
232
+ lines.push("─".repeat(80));
233
+
234
+ return lines.join("\n");
235
+ };
236
+
237
+ export const generateHTMLReport = (report: SessionReport): string => {
238
+ const { summary, events } = report;
239
+ const baseTime = events[0]?.timestamp ?? 0;
240
+
241
+ const eventRows = events.map((e) => {
242
+ const elapsed = ((e.timestamp - baseTime) / 1000).toFixed(2);
243
+ const memory = (e.snapshot.memory.processRss / 1024 / 1024).toFixed(1);
244
+ return `<tr>
245
+ <td>${elapsed}s</td>
246
+ <td><span class="event-type event-${e.type}">${e.type}</span></td>
247
+ <td>${memory} MB</td>
248
+ <td>${e.label}</td>
249
+ </tr>`;
250
+ }).join("\n");
251
+
252
+ return `<!DOCTYPE html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="UTF-8">
256
+ <title>Session Report - ${report.sessionId}</title>
257
+ <style>
258
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }
259
+ .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
260
+ h1 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }
261
+ h2 { color: #555; margin-top: 30px; }
262
+ .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0; }
263
+ .summary-card { background: #f8f9fa; padding: 20px; border-radius: 8px; text-align: center; }
264
+ .summary-card .value { font-size: 2em; font-weight: bold; color: #007bff; }
265
+ .summary-card .label { color: #666; font-size: 0.9em; }
266
+ .status { padding: 15px; border-radius: 8px; margin: 20px 0; }
267
+ .status.success { background: #d4edda; color: #155724; }
268
+ .status.error { background: #f8d7da; color: #721c24; }
269
+ table { width: 100%; border-collapse: collapse; margin-top: 20px; }
270
+ th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
271
+ th { background: #f8f9fa; }
272
+ .event-type { padding: 4px 8px; border-radius: 4px; font-size: 0.85em; }
273
+ .event-baseline { background: #007bff; color: white; }
274
+ .event-interval { background: #6c757d; color: white; }
275
+ .event-pageload { background: #28a745; color: white; }
276
+ .event-click { background: #ffc107; color: #333; }
277
+ .event-popup_open, .event-popup_close { background: #17a2b8; color: white; }
278
+ .event-auth_start, .event-auth_complete { background: #6f42c1; color: white; }
279
+ .event-custom { background: #fd7e14; color: white; }
280
+ .event-error { background: #dc3545; color: white; }
281
+ </style>
282
+ </head>
283
+ <body>
284
+ <div class="container">
285
+ <h1>Session Report</h1>
286
+ <p><strong>Session ID:</strong> ${report.sessionId}</p>
287
+ <p><strong>Duration:</strong> ${(summary.duration / 1000).toFixed(1)} seconds</p>
288
+ <p><strong>Platform:</strong> ${report.platform} (Node ${report.nodeVersion})</p>
289
+
290
+ <div class="summary-grid">
291
+ <div class="summary-card">
292
+ <div class="value">${summary.eventCount}</div>
293
+ <div class="label">Events</div>
294
+ </div>
295
+ <div class="summary-card">
296
+ <div class="value">${summary.peakMemoryMb.toFixed(1)} MB</div>
297
+ <div class="label">Peak Memory</div>
298
+ </div>
299
+ <div class="summary-card">
300
+ <div class="value">${summary.processesSpawned}</div>
301
+ <div class="label">Processes</div>
302
+ </div>
303
+ <div class="summary-card">
304
+ <div class="value">${summary.portsUsed.length}</div>
305
+ <div class="label">Ports Monitored</div>
306
+ </div>
307
+ </div>
308
+
309
+ <div class="status ${summary.hasLeaks ? 'error' : 'success'}">
310
+ ${summary.hasLeaks
311
+ ? `❌ Resource leaks detected: ${summary.orphanedProcesses} orphaned processes, ${summary.portsLeaked} ports leaked`
312
+ : '✅ No resource leaks detected'
313
+ }
314
+ </div>
315
+
316
+ <h2>Event Timeline</h2>
317
+ <table>
318
+ <thead>
319
+ <tr>
320
+ <th>Time</th>
321
+ <th>Event</th>
322
+ <th>Memory</th>
323
+ <th>Label</th>
324
+ </tr>
325
+ </thead>
326
+ <tbody>
327
+ ${eventRows}
328
+ </tbody>
329
+ </table>
330
+ </div>
331
+ </body>
332
+ </html>`;
333
+ };
334
+
335
+ export const exportHTMLReport = (
336
+ report: SessionReport,
337
+ filepath: string
338
+ ): Effect.Effect<void, ExportFailed> =>
339
+ Effect.gen(function* () {
340
+ yield* Effect.logInfo(`Exporting HTML report to ${filepath}`);
341
+
342
+ const html = generateHTMLReport(report);
343
+
344
+ yield* Effect.tryPromise({
345
+ try: () => writeFile(filepath, html),
346
+ catch: (e) => new ExportFailed({
347
+ path: filepath,
348
+ reason: String(e),
349
+ }),
350
+ });
351
+
352
+ yield* Effect.logInfo("HTML report exported");
353
+ });