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.
- package/package.json +11 -7
- package/src/cli.ts +109 -1
- package/src/components/monitor-view.tsx +471 -0
- package/src/contract.ts +94 -0
- package/src/lib/orchestrator.ts +13 -2
- package/src/lib/resource-monitor/assertions.ts +234 -0
- package/src/lib/resource-monitor/command.ts +283 -0
- package/src/lib/resource-monitor/diff.ts +143 -0
- package/src/lib/resource-monitor/errors.ts +127 -0
- package/src/lib/resource-monitor/index.ts +305 -0
- package/src/lib/resource-monitor/platform/darwin.ts +293 -0
- package/src/lib/resource-monitor/platform/index.ts +35 -0
- package/src/lib/resource-monitor/platform/linux.ts +332 -0
- package/src/lib/resource-monitor/platform/windows.ts +298 -0
- package/src/lib/resource-monitor/snapshot.ts +204 -0
- package/src/lib/resource-monitor/types.ts +74 -0
- package/src/lib/session-recorder/errors.ts +102 -0
- package/src/lib/session-recorder/flows/login.ts +210 -0
- package/src/lib/session-recorder/index.ts +361 -0
- package/src/lib/session-recorder/playwright.ts +257 -0
- package/src/lib/session-recorder/report.ts +353 -0
- package/src/lib/session-recorder/server.ts +267 -0
- package/src/lib/session-recorder/types.ts +115 -0
- package/src/plugin.ts +154 -15
|
@@ -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
|
+
});
|