everything-dev 0.1.2 → 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,361 @@
1
+ import { Effect, Logger, LogLevel } from "effect";
2
+ import { createSnapshotWithPlatform, PlatformLive, runSilent } from "../resource-monitor";
3
+ import type { Snapshot } from "../resource-monitor";
4
+ import {
5
+ type SessionRecorderError,
6
+ SessionTimeout,
7
+ SnapshotFailed,
8
+ } from "./errors";
9
+ import {
10
+ type BrowserHandle,
11
+ closeBrowser,
12
+ getBrowserMetrics,
13
+ launchBrowser,
14
+ } from "./playwright";
15
+ import {
16
+ exportHTMLReport,
17
+ exportJSON,
18
+ formatEventTimeline,
19
+ formatReportSummary,
20
+ generateReport,
21
+ } from "./report";
22
+ import {
23
+ checkPortsAvailable,
24
+ shutdownServers,
25
+ startServers,
26
+ waitForPortFree,
27
+ } from "./server";
28
+ import {
29
+ DEFAULT_SESSION_CONFIG,
30
+ type BrowserMetrics,
31
+ type SessionConfig,
32
+ type SessionEvent,
33
+ type SessionEventType,
34
+ type SessionReport,
35
+ type ServerOrchestrator,
36
+ } from "./types";
37
+
38
+ let eventCounter = 0;
39
+
40
+ const generateEventId = (): string => {
41
+ eventCounter += 1;
42
+ return `evt_${Date.now()}_${eventCounter}`;
43
+ };
44
+
45
+ const generateSessionId = (): string => {
46
+ return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
47
+ };
48
+
49
+ export class SessionRecorder {
50
+ private config: SessionConfig;
51
+ private sessionId: string;
52
+ private events: SessionEvent[] = [];
53
+ private startTime: number = 0;
54
+ private endTime: number = 0;
55
+ private intervalHandle: ReturnType<typeof setInterval> | null = null;
56
+ private serverOrchestrator: ServerOrchestrator | null = null;
57
+ private browserHandle: BrowserHandle | null = null;
58
+ private isRecording = false;
59
+
60
+ private constructor(config: SessionConfig) {
61
+ this.config = config;
62
+ this.sessionId = generateSessionId();
63
+ }
64
+
65
+ static create = (
66
+ config?: Partial<SessionConfig>
67
+ ): Effect.Effect<SessionRecorder> =>
68
+ Effect.gen(function* () {
69
+ const fullConfig = { ...DEFAULT_SESSION_CONFIG, ...config };
70
+ yield* Effect.logInfo(`Creating SessionRecorder: ${JSON.stringify(fullConfig)}`);
71
+ return new SessionRecorder(fullConfig);
72
+ });
73
+
74
+ getConfig(): SessionConfig {
75
+ return { ...this.config };
76
+ }
77
+
78
+ getSessionId(): string {
79
+ return this.sessionId;
80
+ }
81
+
82
+ getEvents(): SessionEvent[] {
83
+ return [...this.events];
84
+ }
85
+
86
+ isActive(): boolean {
87
+ return this.isRecording;
88
+ }
89
+
90
+ takeSnapshot(): Effect.Effect<Snapshot, SnapshotFailed> {
91
+ const self = this;
92
+ return Effect.gen(function* () {
93
+ const snapshot = yield* Effect.tryPromise({
94
+ try: () => runSilent(createSnapshotWithPlatform({ ports: self.config.ports })),
95
+ catch: (e) => new SnapshotFailed({ reason: String(e) }),
96
+ });
97
+ return snapshot;
98
+ });
99
+ }
100
+
101
+ recordEvent(
102
+ type: SessionEventType,
103
+ label: string,
104
+ metadata?: Record<string, unknown>
105
+ ): Effect.Effect<SessionEvent, SnapshotFailed> {
106
+ const self = this;
107
+ return Effect.gen(function* () {
108
+ const snapshot = yield* self.takeSnapshot();
109
+
110
+ let browserMetrics: BrowserMetrics | undefined;
111
+ if (self.browserHandle) {
112
+ const metricsResult = yield* Effect.either(
113
+ getBrowserMetrics(self.browserHandle.page)
114
+ );
115
+ if (metricsResult._tag === "Right") {
116
+ browserMetrics = metricsResult.right;
117
+ }
118
+ }
119
+
120
+ const event: SessionEvent = {
121
+ id: generateEventId(),
122
+ timestamp: Date.now(),
123
+ type,
124
+ label,
125
+ snapshot,
126
+ browserMetrics,
127
+ url: self.browserHandle?.page.url(),
128
+ metadata,
129
+ };
130
+
131
+ self.events.push(event);
132
+ yield* Effect.logDebug(`Event recorded: ${type} - ${label}`);
133
+
134
+ return event;
135
+ });
136
+ }
137
+
138
+ startServers(
139
+ mode: "start" | "dev" = "start"
140
+ ): Effect.Effect<void, SessionRecorderError> {
141
+ const self = this;
142
+ return Effect.gen(function* () {
143
+ yield* Effect.logInfo(`Starting servers in ${mode} mode`);
144
+
145
+ const portsAvailable = yield* checkPortsAvailable(self.config.ports);
146
+ if (!portsAvailable) {
147
+ yield* Effect.logWarning("Some ports already in use - proceeding anyway");
148
+ }
149
+
150
+ const orchestrator = yield* startServers(mode, {
151
+ port: self.config.ports[0],
152
+ });
153
+
154
+ self.serverOrchestrator = orchestrator;
155
+ yield* Effect.logInfo("Servers started successfully");
156
+ });
157
+ }
158
+
159
+ stopServers(): Effect.Effect<void> {
160
+ const self = this;
161
+ return Effect.gen(function* () {
162
+ if (self.serverOrchestrator) {
163
+ yield* shutdownServers(self.serverOrchestrator);
164
+ self.serverOrchestrator = null;
165
+ }
166
+ });
167
+ }
168
+
169
+ launchBrowser(): Effect.Effect<BrowserHandle, SessionRecorderError> {
170
+ const self = this;
171
+ return Effect.gen(function* () {
172
+ yield* Effect.logInfo(`Launching browser (headless: ${self.config.headless})`);
173
+
174
+ const handle = yield* launchBrowser(self.config.headless);
175
+ self.browserHandle = handle;
176
+
177
+ return handle;
178
+ });
179
+ }
180
+
181
+ closeBrowser(): Effect.Effect<void> {
182
+ const self = this;
183
+ return Effect.gen(function* () {
184
+ if (self.browserHandle) {
185
+ yield* closeBrowser(self.browserHandle);
186
+ self.browserHandle = null;
187
+ }
188
+ });
189
+ }
190
+
191
+ getBrowser(): BrowserHandle | null {
192
+ return this.browserHandle;
193
+ }
194
+
195
+ startRecording(): Effect.Effect<void, SnapshotFailed> {
196
+ const self = this;
197
+ return Effect.gen(function* () {
198
+ if (self.isRecording) {
199
+ yield* Effect.logWarning("Already recording");
200
+ return;
201
+ }
202
+
203
+ self.isRecording = true;
204
+ self.startTime = Date.now();
205
+ self.events = [];
206
+
207
+ yield* Effect.logInfo("Starting session recording");
208
+
209
+ yield* self.recordEvent("baseline", "session_start");
210
+
211
+ if (self.config.snapshotIntervalMs > 0) {
212
+ self.intervalHandle = setInterval(() => {
213
+ Effect.runPromise(
214
+ self.recordEvent("interval", "auto_snapshot").pipe(
215
+ Effect.catchAll(() => Effect.void)
216
+ )
217
+ );
218
+ }, self.config.snapshotIntervalMs);
219
+ }
220
+
221
+ yield* Effect.logInfo(`Recording started with ${self.config.snapshotIntervalMs}ms interval`);
222
+ });
223
+ }
224
+
225
+ stopRecording(): Effect.Effect<SessionReport, SnapshotFailed> {
226
+ const self = this;
227
+ return Effect.gen(function* () {
228
+ if (!self.isRecording) {
229
+ yield* Effect.logWarning("Not recording");
230
+ return generateReport(
231
+ self.sessionId,
232
+ self.config,
233
+ self.events,
234
+ self.startTime,
235
+ Date.now()
236
+ );
237
+ }
238
+
239
+ if (self.intervalHandle) {
240
+ clearInterval(self.intervalHandle);
241
+ self.intervalHandle = null;
242
+ }
243
+
244
+ yield* self.recordEvent("custom", "session_end");
245
+
246
+ self.isRecording = false;
247
+ self.endTime = Date.now();
248
+
249
+ yield* Effect.logInfo("Recording stopped");
250
+
251
+ const report = generateReport(
252
+ self.sessionId,
253
+ self.config,
254
+ self.events,
255
+ self.startTime,
256
+ self.endTime
257
+ );
258
+
259
+ return report;
260
+ });
261
+ }
262
+
263
+ exportReport(
264
+ filepath: string,
265
+ format: "json" | "html" = "json"
266
+ ): Effect.Effect<void, SessionRecorderError> {
267
+ const self = this;
268
+ return Effect.gen(function* () {
269
+ const report = generateReport(
270
+ self.sessionId,
271
+ self.config,
272
+ self.events,
273
+ self.startTime,
274
+ self.endTime || Date.now()
275
+ );
276
+
277
+ if (format === "html") {
278
+ yield* exportHTMLReport(report, filepath);
279
+ } else {
280
+ yield* exportJSON(report, filepath);
281
+ }
282
+ });
283
+ }
284
+
285
+ generateReport(): SessionReport {
286
+ return generateReport(
287
+ this.sessionId,
288
+ this.config,
289
+ this.events,
290
+ this.startTime,
291
+ this.endTime || Date.now()
292
+ );
293
+ }
294
+
295
+ printSummary(): Effect.Effect<void> {
296
+ const self = this;
297
+ return Effect.sync(() => {
298
+ const report = self.generateReport();
299
+ console.log(formatReportSummary(report));
300
+ });
301
+ }
302
+
303
+ printTimeline(): Effect.Effect<void> {
304
+ const self = this;
305
+ return Effect.sync(() => {
306
+ console.log(formatEventTimeline(self.events));
307
+ });
308
+ }
309
+
310
+ cleanup(): Effect.Effect<void> {
311
+ const self = this;
312
+ return Effect.gen(function* () {
313
+ yield* Effect.logInfo("Cleaning up session recorder");
314
+
315
+ if (self.intervalHandle) {
316
+ clearInterval(self.intervalHandle);
317
+ self.intervalHandle = null;
318
+ }
319
+
320
+ yield* self.closeBrowser();
321
+ yield* self.stopServers();
322
+
323
+ yield* Effect.logInfo("Cleanup complete");
324
+ });
325
+ }
326
+ }
327
+
328
+ export const runSession = <E>(
329
+ effect: Effect.Effect<void, E>
330
+ ): Promise<void> =>
331
+ effect.pipe(
332
+ Effect.provide(PlatformLive),
333
+ Logger.withMinimumLogLevel(LogLevel.Info),
334
+ Effect.runPromise
335
+ );
336
+
337
+ export const runSessionSilent = <E>(
338
+ effect: Effect.Effect<void, E>
339
+ ): Promise<void> =>
340
+ effect.pipe(
341
+ Effect.provide(PlatformLive),
342
+ Logger.withMinimumLogLevel(LogLevel.Error),
343
+ Effect.runPromise
344
+ );
345
+
346
+ export const runSessionDebug = <E>(
347
+ effect: Effect.Effect<void, E>
348
+ ): Promise<void> =>
349
+ effect.pipe(
350
+ Effect.provide(PlatformLive),
351
+ Logger.withMinimumLogLevel(LogLevel.Debug),
352
+ Effect.runPromise
353
+ );
354
+
355
+ export * from "./errors";
356
+ export * from "./types";
357
+ export * from "./server";
358
+ export * from "./playwright";
359
+ export * from "./report";
360
+ export { runLoginFlow, runNavigationFlow, runClickFlow } from "./flows/login";
361
+ export { diffSnapshots, hasLeaks, type Snapshot, type SnapshotDiff } from "../resource-monitor";
@@ -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)));