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.
- 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/nova.ts +11 -11
- package/src/lib/orchestrator.ts +13 -2
- package/src/lib/process.ts +50 -5
- 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 +161 -17
- package/src/types.ts +8 -0
|
@@ -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)));
|