Skip to content

Instantly share code, notes, and snippets.

@WomB0ComB0
Created November 19, 2025 08:57
Show Gist options
  • Select an option

  • Save WomB0ComB0/1b32626db6bf8d45290c93ce7936d0e3 to your computer and use it in GitHub Desktop.

Select an option

Save WomB0ComB0/1b32626db6bf8d45290c93ce7936d0e3 to your computer and use it in GitHub Desktop.
ui-capture - Enhanced with AI-generated documentation
/**
* Copyright (c) 2025 Mike Odnis
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/**
* @fileoverview Web UI Screenshot and Video Capture Tool
* @module ui-capture
* @description Automated screenshot and video recording tool using Effect-TS
* for type-safe, composable, and testable operations.
*
* Features:
* - Full-page screenshots for all routes
* - Video recordings of user interactions
* - Responsive viewport testing
* - Automatic route discovery
* - Effect-based error handling
* - Structured concurrency
*
* @author Refactored with Effect-TS
* @version 3.0.0
* @license Apache-2.0
*/
import { Schema as S } from "@effect/schema";
import { Context, Effect, Layer, Option, Queue, Ref } from "effect";
import fs from 'node:fs/promises';
import path from 'node:path';
import { type Browser, chromium, type Page } from 'playwright';
// --- Schema Definitions ---
/**
* Viewport configuration for responsive testing
*/
export class ViewportConfig extends S.Class<ViewportConfig>("ViewportConfig")({
name: S.String,
width: S.Number.pipe(S.int(), S.positive()),
height: S.Number.pipe(S.int(), S.positive()),
}) {}
/**
* Screenshot paths for different formats
*/
export class ScreenshotPaths extends S.Class<ScreenshotPaths>("ScreenshotPaths")({
png: S.String,
webp: S.String,
jpg: S.String,
}) {}
/**
* Video quality paths
*/
export class VideoQualityPaths extends S.Class<VideoQualityPaths>("VideoQualityPaths")({
high: S.String,
medium: S.String,
low: S.String,
}) {}
/**
* Capture result for a single route
*/
export class CaptureResult extends S.Class<CaptureResult>("CaptureResult")({
url: S.String,
route: S.String,
screenshots: S.Record({ key: S.String, value: ScreenshotPaths }),
videos: S.optional(S.Record({ key: S.String, value: VideoQualityPaths })),
error: S.optional(S.String),
timestamp: S.Number.pipe(S.int()),
}) {}
/**
* Video recording options with defaults
*/
const VideoOptionsFields = {
duration: S.Number.pipe(S.int(), S.positive()),
interactions: S.Boolean,
};
export class VideoOptions extends S.Class<VideoOptions>("VideoOptions")(VideoOptionsFields) {
static readonly Default = new VideoOptions({ duration: 10000, interactions: true });
}
/**
* Main capture configuration with defaults
*/
const CaptureConfigFields = {
outputDir: S.String,
captureVideo: S.Boolean,
viewports: S.Array(ViewportConfig),
maxDepth: S.Number.pipe(S.int(), S.nonNegative()),
waitTime: S.Number.pipe(S.int(), S.nonNegative()),
videoOptions: VideoOptions,
includeSubdomains: S.Boolean,
allowedHosts: S.Array(S.String),
routeConcurrency: S.Number.pipe(S.int(), S.positive()),
};
export class CaptureConfig extends S.Class<CaptureConfig>("CaptureConfig")(CaptureConfigFields) {
static readonly Default = new CaptureConfig({
outputDir: 'ui-captures',
captureVideo: false,
viewports: [
new ViewportConfig({ name: 'desktop', width: 1920, height: 1080 }),
new ViewportConfig({ name: 'tablet', width: 768, height: 1024 }),
new ViewportConfig({ name: 'mobile', width: 375, height: 667 }),
],
maxDepth: 2,
waitTime: 2000,
videoOptions: VideoOptions.Default,
includeSubdomains: false,
allowedHosts: [],
routeConcurrency: 2,
});
}
type RouteTask = {
readonly type: 'route';
readonly url: string;
readonly depth: number;
readonly normalizedUrl: string;
};
type ShutdownTask = {
readonly type: 'shutdown';
};
type QueueTask = RouteTask | ShutdownTask;
const ShutdownSignal: ShutdownTask = { type: 'shutdown' } as const;
type ViewportConfigInput = ViewportConfig | {
readonly name: string;
readonly width: number;
readonly height: number;
};
type VideoOptionsInput = VideoOptions | {
readonly duration?: number;
readonly interactions?: boolean;
};
export type CaptureConfigOverrides = Partial<{
outputDir: string;
captureVideo: boolean;
viewports: ReadonlyArray<ViewportConfigInput>;
maxDepth: number;
waitTime: number;
videoOptions: VideoOptionsInput;
includeSubdomains: boolean;
allowedHosts: ReadonlyArray<string>;
routeConcurrency: number;
}>;
const toViewportInstance = (viewport: ViewportConfigInput): ViewportConfig =>
viewport instanceof ViewportConfig
? viewport
: new ViewportConfig(viewport);
const toVideoOptionsInstance = (
input: VideoOptionsInput | undefined,
fallback: VideoOptions
): VideoOptions =>
input instanceof VideoOptions
? input
: new VideoOptions({
duration: fallback.duration,
interactions: fallback.interactions,
...(input ?? {}),
});
/**
* Convenience helper for merging partial overrides with sane defaults to produce
* a strongly-typed `CaptureConfig` instance.
*/
export const createCaptureConfig = (
overrides: CaptureConfigOverrides = {}
): CaptureConfig => {
const base = CaptureConfig.Default;
const viewports = overrides.viewports
? overrides.viewports.map(toViewportInstance)
: base.viewports.map(toViewportInstance);
const videoOptions =
overrides.videoOptions !== undefined
? toVideoOptionsInstance(overrides.videoOptions, base.videoOptions)
: base.videoOptions;
return new CaptureConfig({
...base,
...overrides,
viewports,
videoOptions,
allowedHosts: overrides.allowedHosts
? Array.from(overrides.allowedHosts)
: base.allowedHosts,
});
};
/**
* Capture report summary
*/
export class CaptureReport extends S.Class<CaptureReport>("CaptureReport")({
timestamp: S.String,
totalRoutes: S.Number.pipe(S.int(), S.nonNegative()),
successfulCaptures: S.Number.pipe(S.int(), S.nonNegative()),
failedCaptures: S.Number.pipe(S.int(), S.nonNegative()),
viewports: S.Array(ViewportConfig),
results: S.Array(S.Struct({
url: S.String,
route: S.String,
screenshots: S.Array(S.String),
hasVideo: S.Boolean,
error: S.optional(S.String),
})),
}) {}
// --- Error Types ---
export class BrowserError extends S.TaggedError<BrowserError>()("BrowserError", {
message: S.String,
cause: S.Unknown,
}) {}
export class CaptureError extends S.TaggedError<CaptureError>()("CaptureError", {
url: S.String,
message: S.String,
cause: S.Unknown,
}) {}
export class FileSystemError extends S.TaggedError<FileSystemError>()("FileSystemError", {
path: S.String,
operation: S.String,
cause: S.Unknown,
}) {}
// --- Context Tags ---
export class CaptureConfigTag extends Context.Tag("CaptureConfig")<
CaptureConfigTag,
CaptureConfig
>() {}
// --- Service Definition ---
export class UICaptureService extends Effect.Service<UICaptureService>()("UICaptureService", {
effect: Effect.gen(function* () {
const cfg = yield* CaptureConfigTag;
let browser: Browser | null = null;
const processedRoutes = new Set<string>();
let allowedHostnames = new Set<string>();
let hostSuffixes = new Set<string>();
// Initialize browser
const initialize = Effect.tryPromise({
try: async () => {
await fs.mkdir(cfg.outputDir, { recursive: true });
browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
});
console.log('✓ Browser initialized');
return browser;
},
catch: (error) => new BrowserError({ message: 'Failed to initialize browser', cause: error }),
});
// Cleanup browser
const cleanup = Effect.tryPromise({
try: async () => {
if (browser) {
await browser.close();
browser = null;
}
processedRoutes.clear();
console.log('✓ Browser cleanup complete');
},
catch: (error) => new BrowserError({ message: 'Failed to cleanup browser', cause: error }),
});
/**
* Normalizes a hostname / origin string to a canonical lowercase form without protocols,
* `www.` prefix, paths, or trailing slashes.
*
* @param host Raw host/origin string provided via config or discovered link.
* @returns Canonical host string suitable for equality comparisons.
*/
const canonicalizeHost = (host: string): string =>
host
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/, '')
.replace(/^www\./i, '')
.toLowerCase();
/**
* Expands a host into all possible suffix combinations.
* Example: `app.internal.example.com` => [
* `app.internal.example.com`, `internal.example.com`, `example.com`, `com`
* ]
*
* Used to quickly assert that a given hostname falls under a whitelisted root.
*
* @param host Canonical host string.
* @returns Array of suffixes ordered from most specific to least.
*/
const computeHostSuffixes = (host: string): readonly string[] => {
const segments = canonicalizeHost(host).split('.').filter(Boolean);
const suffixes: string[] = [];
for (let i = 0; i < segments.length; i++) {
suffixes.push(segments.slice(i).join('.'));
}
return suffixes;
};
/**
* Populates the in-memory host allow-lists based on the root URL
* passed to `captureWebsite` and any additional `allowedHosts`.
*
* @param primaryHost Root hostname derived from the starting URL.
*/
const hydrateDomainFilters = (primaryHost: string): void => {
const configuredHosts = (cfg.allowedHosts ?? []).map(canonicalizeHost);
const normalizedPrimary = canonicalizeHost(primaryHost);
allowedHostnames = new Set([normalizedPrimary, ...configuredHosts].filter(Boolean));
hostSuffixes = new Set(
Array.from(allowedHostnames).flatMap((host) => computeHostSuffixes(host))
);
};
/**
* Determines whether a hostname should be crawled, observing explicit allow lists
* and the `includeSubdomains` flag.
*
* @param hostname Hostname extracted from a candidate URL.
* @returns True if the URL should be included in the crawl graph.
*/
const hostMatchesFilters = (hostname: string): boolean => {
const normalized = canonicalizeHost(hostname);
if (allowedHostnames.has(normalized)) {
return true;
}
if (!cfg.includeSubdomains) {
return false;
}
for (const suffix of hostSuffixes) {
if (normalized === suffix || normalized.endsWith(`.${suffix}`)) {
return true;
}
}
return false;
};
// Normalize URL for comparison
/**
* Normalizes a URL string to `{origin}{pathname}` with a trailing slash removed.
* Acts as a cache key to avoid re-processing identical routes.
*/
const normalizeUrl = (url: string): Effect.Effect<string> =>
Effect.sync(() => {
try {
const urlObj = new URL(url);
const normalized = `${urlObj.origin}${urlObj.pathname}`.replace(/\/$/, '');
return normalized || urlObj.origin + '/';
} catch {
return url;
}
});
// Get route name from URL
/**
* Builds a filesystem-friendly slug from a URL pathname.
* Defaults to `root` for `/` and `invalid-url` when parsing fails.
*/
const getRouteName = (url: string): Effect.Effect<string> =>
Effect.sync(() => {
try {
const urlObj = new URL(url);
return urlObj.pathname
.replace(/^\/|\/$/g, '')
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.toLowerCase() || 'root';
} catch {
return 'invalid-url';
}
});
// Create directory structure
/**
* Ensures the necessary directory hierarchy exists for storing screenshots and videos.
*
* @param routeDir Absolute path for the current route capture output.
*/
const createDirectories = (routeDir: string): Effect.Effect<void, FileSystemError> =>
Effect.tryPromise({
try: async () => {
await fs.mkdir(path.join(routeDir, 'screenshots', 'png'), { recursive: true });
await fs.mkdir(path.join(routeDir, 'screenshots', 'webp'), { recursive: true });
await fs.mkdir(path.join(routeDir, 'screenshots', 'jpg'), { recursive: true });
if (cfg.captureVideo) {
await fs.mkdir(path.join(routeDir, 'videos', 'high-quality'), { recursive: true });
await fs.mkdir(path.join(routeDir, 'videos', 'medium-quality'), { recursive: true });
await fs.mkdir(path.join(routeDir, 'videos', 'low-quality'), { recursive: true });
}
},
catch: (error) => new FileSystemError({
path: routeDir,
operation: 'mkdir',
cause: error,
}),
});
// Extract same-origin links
const linkFilterConcurrency = 32;
/**
* Extracts eligible links from the current page, normalizing and deduplicating them.
* Filtering occurs concurrently to keep up with link-dense pages.
*
* @param page Playwright page currently being captured.
* @returns Array of absolute URLs that satisfy domain constraints.
*/
const extractLinks = (page: Page): Effect.Effect<readonly string[], never> =>
Effect.tryPromise({
try: async () => {
return await page.$$eval('a[href]', (anchors) =>
Array.from(anchors, (anchor) => (anchor as HTMLAnchorElement).href)
);
},
catch: () => [] as readonly string[],
}).pipe(
Effect.flatMap((links) =>
Effect.forEach(
links,
(link) =>
Effect.sync(() => {
try {
if (link === '#' || link.endsWith('#')) {
return Option.none<string>();
}
const url = new URL(link);
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
!hostMatchesFilters(url.hostname)
) {
return Option.none<string>();
}
url.hash = '';
return Option.some(url.toString());
} catch {
return Option.none<string>();
}
}),
{
concurrency: Math.max(
1,
Math.min(linkFilterConcurrency, Math.max(1, links.length))
),
}
).pipe(
Effect.map((options) => {
const uniqueLinks = new Set<string>();
for (const option of options) {
if (Option.isSome(option)) {
uniqueLinks.add(option.value);
}
}
return Array.from(uniqueLinks);
})
)
),
Effect.orElseSucceed(() => [] as readonly string[])
);
// Capture screenshot in all formats
/**
* Captures PNG/WEBP/JPEG screenshots for a single viewport configuration.
*
* @param page Playwright page instance.
* @param viewport Viewport configuration entry.
* @param routeDir Output directory for the route.
* @param timestamp Timestamp suffix to keep assets unique.
*/
const captureScreenshots = (
page: Page,
viewport: ViewportConfig,
routeDir: string,
timestamp: string
): Effect.Effect<ScreenshotPaths, CaptureError> =>
Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => page.setViewportSize({
width: viewport.width,
height: viewport.height,
}),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to set viewport',
cause: error,
}),
});
yield* Effect.sleep(1000);
const baseFilename = `${viewport.name}_${viewport.width}x${viewport.height}_${timestamp}`;
const pngPath = path.join(routeDir, 'screenshots', 'png', `${baseFilename}.png`);
const webpPath = path.join(routeDir, 'screenshots', 'webp', `${baseFilename}.webp`);
const jpgPath = path.join(routeDir, 'screenshots', 'jpg', `${baseFilename}.jpg`);
yield* Effect.all([
Effect.tryPromise({
try: () => page.screenshot({ path: pngPath, fullPage: true, type: 'png' }),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to capture PNG',
cause: error,
}),
}),
Effect.tryPromise({
try: () => page.screenshot({ path: webpPath, fullPage: true, type: 'jpeg', quality: 90 }),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to capture WebP',
cause: error,
}),
}),
Effect.tryPromise({
try: () => page.screenshot({ path: jpgPath, fullPage: true, type: 'jpeg', quality: 85 }),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to capture JPEG',
cause: error,
}),
}),
], { concurrency: 3 });
console.log(` ✓ Screenshots saved: ${baseFilename}`);
return new ScreenshotPaths({ png: pngPath, webp: webpPath, jpg: jpgPath });
});
// Capture video for viewport
/**
* Records videos in multiple quality tiers for a viewport, if enabled.
*
* @param page Reference page (used for URL + wait timings).
* @param viewport Active viewport configuration.
* @param routeDir Output directory for video assets.
* @param timestamp Unique suffix for filenames.
*/
const captureVideo = (
page: Page,
viewport: ViewportConfig,
routeDir: string,
timestamp: string
): Effect.Effect<VideoQualityPaths, CaptureError | FileSystemError> =>
Effect.gen(function* () {
if (!browser) {
return yield* Effect.fail(new CaptureError({
url: page.url(),
message: 'Browser not initialized',
cause: null,
}));
}
const baseFilename = `${viewport.name}_${viewport.width}x${viewport.height}_${timestamp}`;
const qualities = [
{ name: 'high' as const, scale: 1.0, dir: 'high-quality' },
{ name: 'medium' as const, scale: 0.75, dir: 'medium-quality' },
{ name: 'low' as const, scale: 0.5, dir: 'low-quality' },
];
const paths = yield* Effect.all(
qualities.map((quality) =>
Effect.gen(function* () {
const context = yield* Effect.tryPromise({
try: () => browser!.newContext({
recordVideo: {
dir: path.join(routeDir, 'videos', quality.dir),
size: {
width: Math.floor(viewport.width * quality.scale),
height: Math.floor(viewport.height * quality.scale),
},
},
viewport: { width: viewport.width, height: viewport.height },
}),
catch: (error) => new CaptureError({
url: page.url(),
message: `Failed to create ${quality.name} quality context`,
cause: error,
}),
});
const videoPage = yield* Effect.tryPromise({
try: () => context.newPage(),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to create video page',
cause: error,
}),
});
yield* Effect.tryPromise({
try: () => videoPage.goto(page.url(), { waitUntil: 'networkidle', timeout: 30000 }),
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to navigate video page',
cause: error,
}),
});
yield* Effect.sleep(cfg.waitTime);
// Perform interactions if enabled
if (cfg.videoOptions.interactions) {
const scrollSteps = 5;
const scrollDelay = cfg.videoOptions.duration / (scrollSteps + 1);
for (let i = 0; i < scrollSteps; i++) {
yield* Effect.tryPromise({
try: () => videoPage.evaluate((step) => {
window.scrollTo({
top: (document.body.scrollHeight / 5) * step,
behavior: 'smooth',
});
}, i + 1),
catch: () => undefined,
});
yield* Effect.sleep(scrollDelay);
}
yield* Effect.tryPromise({
try: () => videoPage.evaluate(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}),
catch: () => undefined,
});
yield* Effect.sleep(1000);
} else {
yield* Effect.sleep(cfg.videoOptions.duration);
}
yield* Effect.tryPromise({
try: () => videoPage.close(),
catch: () => undefined,
});
const videoPath = yield* Effect.tryPromise({
try: async () => {
const vp = await videoPage.video()?.path();
await context.close();
return vp;
},
catch: (error) => new CaptureError({
url: page.url(),
message: 'Failed to get video path',
cause: error,
}),
});
if (!videoPath) {
return yield* Effect.fail(new CaptureError({
url: page.url(),
message: 'Video path is null',
cause: null,
}));
}
const newPath = path.join(routeDir, 'videos', quality.dir, `${baseFilename}.webm`);
yield* Effect.tryPromise({
try: () => fs.rename(videoPath, newPath),
catch: (error) => new FileSystemError({
path: newPath,
operation: 'rename',
cause: error,
}),
});
return [quality.name, newPath] as const;
}).pipe(
Effect.catchAll((error) => {
console.error(`Failed to capture ${quality.name} quality video:`, error);
return Effect.succeed([quality.name, ''] as const);
})
)
),
{ concurrency: 1 }
);
const videoPaths: Record<'high' | 'medium' | 'low', string> = {
high: paths[0][1],
medium: paths[1][1],
low: paths[2][1],
};
console.log(` ✓ Videos saved: ${baseFilename}`);
return new VideoQualityPaths(videoPaths);
});
// Capture page across all viewports
/**
* Runs the full capture pipeline (screenshots + optional video) for every viewport.
*
* @param page Current Playwright page.
* @param url Route URL being captured.
* @returns Structured `CaptureResult` with asset metadata.
*/
const capturePage = (page: Page, url: string): Effect.Effect<CaptureResult, CaptureError | FileSystemError> =>
Effect.gen(function* () {
const route = yield* getRouteName(url);
const routeDir = path.join(cfg.outputDir, route);
yield* createDirectories(routeDir);
yield* Effect.tryPromise({
try: () => page.waitForLoadState('networkidle'),
catch: (error) => new CaptureError({ url, message: 'Failed to wait for page load', cause: error }),
});
yield* Effect.sleep(cfg.waitTime);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const screenshotResults = yield* Effect.all(
cfg.viewports.map((viewport: ViewportConfig) =>
Effect.gen(function* () {
console.log(` Capturing ${viewport.name} (${viewport.width}x${viewport.height})`);
const screenshots = yield* captureScreenshots(page, viewport, routeDir, timestamp);
const videos = cfg.captureVideo
? Option.some(yield* captureVideo(page, viewport, routeDir, timestamp))
: Option.none();
return [viewport.name, { screenshots, videos }] as const;
})
),
{ concurrency: 1 }
);
const screenshots: Record<string, ScreenshotPaths> = {};
const videos: Record<string, VideoQualityPaths> = {};
for (const [name, data] of screenshotResults) {
screenshots[name] = data.screenshots;
if (Option.isSome(data.videos)) {
videos[name] = data.videos.value;
}
}
return new CaptureResult({
url,
route,
screenshots,
videos: Object.keys(videos).length > 0 ? videos : undefined,
timestamp: Date.now(),
});
});
/**
* Processes a queued route using a persistent worker page, scheduling discovered links.
*/
const processRouteTask = (
page: Page,
task: RouteTask,
results: Map<string, CaptureResult>,
scheduleNext: (url: string, depth: number) => Effect.Effect<void, never>,
workerLabel: string
): Effect.Effect<void, CaptureError | FileSystemError> =>
Effect.gen(function* () {
const indent = ' '.repeat(task.depth);
console.log(`\n${indent}[Worker ${workerLabel}] [Depth ${task.depth}] Capturing: ${task.url}`);
yield* Effect.tryPromise({
try: () => page.goto(task.url, { waitUntil: 'networkidle', timeout: 30000 }),
catch: (error) => new CaptureError({ url: task.url, message: 'Failed to navigate', cause: error }),
});
const discoveredLinks = task.depth < cfg.maxDepth
? yield* extractLinks(page)
: [];
console.log(`${indent} Found ${discoveredLinks.length} internal links`);
const result = yield* capturePage(page, task.url);
results.set(task.normalizedUrl, result);
if (discoveredLinks.length > 0) {
const schedulingConcurrency = Math.max(
1,
Math.min(cfg.routeConcurrency, discoveredLinks.length)
);
yield* Effect.forEach(
discoveredLinks,
(link) => scheduleNext(link, task.depth + 1),
{ concurrency: schedulingConcurrency }
);
}
});
// Generate reports
/**
* Persists machine-readable (`capture-report.json`) and human-readable (`REPORT.md`) summaries.
*
* @param results Map of normalized URLs to their capture results.
*/
const generateReports = (results: Map<string, CaptureResult>): Effect.Effect<void, FileSystemError> =>
Effect.gen(function* () {
const resultsArray = Array.from(results.values());
const successful = resultsArray.filter((r) => !r.error);
const failed = resultsArray.filter((r) => !!r.error);
const report = new CaptureReport({
timestamp: new Date().toISOString(),
totalRoutes: results.size,
successfulCaptures: successful.length,
failedCaptures: failed.length,
viewports: cfg.viewports,
results: resultsArray.map((result) => ({
url: result.url,
route: result.route,
screenshots: Object.keys(result.screenshots),
hasVideo: !!result.videos,
error: result.error,
})),
});
yield* Effect.tryPromise({
try: () => fs.writeFile(
path.join(cfg.outputDir, 'capture-report.json'),
JSON.stringify(report, null, 2)
),
catch: (error) => new FileSystemError({
path: path.join(cfg.outputDir, 'capture-report.json'),
operation: 'writeFile',
cause: error,
}),
});
const markdown = generateMarkdown(results, successful, failed);
yield* Effect.tryPromise({
try: () => fs.writeFile(
path.join(cfg.outputDir, 'REPORT.md'),
markdown
),
catch: (error) => new FileSystemError({
path: path.join(cfg.outputDir, 'REPORT.md'),
operation: 'writeFile',
cause: error,
}),
});
console.log('\n✓ Reports generated');
});
// Generate markdown report
/**
* Builds a Markdown report aggregating all successfully captured routes and failed attempts.
*
* @param results Full result map.
* @param successful Subset of successful captures.
* @param failed Subset of failed captures.
* @returns Markdown document string.
*/
const generateMarkdown = (
results: Map<string, CaptureResult>,
successful: CaptureResult[],
failed: CaptureResult[]
): string => {
let md = `# UI Capture Report\n\n`;
md += `Generated: ${new Date().toISOString()}\n\n`;
md += `## Summary\n\n`;
md += `- Total Routes: ${results.size}\n`;
md += `- Successful: ${successful.length}\n`;
md += `- Failed: ${failed.length}\n\n`;
md += `## Captured Routes\n\n`;
for (const result of successful) {
md += `### ${result.route}\n\n`;
md += `**URL:** ${result.url}\n\n`;
for (const [viewport, formats] of Object.entries(result.screenshots)) {
md += `#### ${viewport.toUpperCase()} (Screenshots)\n\n`;
const relPng = path.relative(cfg.outputDir, formats.png).replace(/\\/g, '/');
const relWebp = path.relative(cfg.outputDir, formats.webp).replace(/\\/g, '/');
const relJpg = path.relative(cfg.outputDir, formats.jpg).replace(/\\/g, '/');
md += `- PNG (lossless): [View](${relPng})\n`;
md += `- WebP (optimized): [View](${relWebp})\n`;
md += `- JPEG (compatible): [View](${relJpg})\n\n`;
if (result.videos && result.videos[viewport]) {
md += `**${viewport.toUpperCase()} Videos:**\n\n`;
const videos = result.videos[viewport];
const relHigh = path.relative(cfg.outputDir, videos.high).replace(/\\/g, '/');
const relMedium = path.relative(cfg.outputDir, videos.medium).replace(/\\/g, '/');
const relLow = path.relative(cfg.outputDir, videos.low).replace(/\\/g, '/');
md += `- High Quality (1:1 scale): [Watch](${relHigh})\n`;
md += `- Medium Quality (0.75x scale): [Watch](${relMedium})\n`;
md += `- Low Quality (0.5x scale): [Watch](${relLow})\n\n`;
}
}
md += '---\n\n';
}
if (failed.length > 0) {
md += `## Failed Captures\n\n`;
for (const result of failed) {
md += `- ${result.url}: ${result.error}\n`;
}
}
return md;
};
// Main capture function
/**
* Public entry point for capturing an entire website.
*
* @param url Absolute URL to the root route.
* @returns Map of normalized URLs to capture metadata.
*/
const captureWebsite = (url: string): Effect.Effect<Map<string, CaptureResult>, BrowserError | CaptureError | FileSystemError> =>
Effect.gen(function* () {
console.log('Starting UI capture for:', url);
const urlObj = new URL(url);
hydrateDomainFilters(urlObj.hostname);
const results = new Map<string, CaptureResult>();
yield* Effect.acquireUseRelease(
initialize,
() => Effect.gen(function* () {
if (!browser) {
return yield* Effect.fail(new CaptureError({
url,
message: 'Browser not initialized',
cause: null,
}));
}
const queueCapacity = Math.max(32, cfg.routeConcurrency * 8);
const taskQueue = yield* Queue.bounded<QueueTask>(queueCapacity);
const pendingTasks = yield* Ref.make(0);
const shutdownNotified = yield* Ref.make(false);
const signalShutdown = (): Effect.Effect<void, never> =>
Effect.gen(function* () {
const already = yield* Ref.get(shutdownNotified);
if (already) {
return;
}
yield* Ref.set(shutdownNotified, true);
for (let i = 0; i < cfg.routeConcurrency; i++) {
yield* Queue.offer(taskQueue, ShutdownSignal);
}
});
const scheduleRoute = (routeUrl: string, depth: number): Effect.Effect<void, never> =>
Effect.gen(function* () {
if (depth > cfg.maxDepth) {
return;
}
if (yield* Ref.get(shutdownNotified)) {
return;
}
let hostname: string;
try {
hostname = new URL(routeUrl).hostname;
} catch {
return;
}
if (!hostMatchesFilters(hostname)) {
return;
}
const normalizedUrl = yield* normalizeUrl(routeUrl);
const taskOption = yield* Effect.sync(() => {
if (processedRoutes.has(normalizedUrl)) {
return Option.none<RouteTask>();
}
processedRoutes.add(normalizedUrl);
return Option.some<RouteTask>({
type: 'route',
url: routeUrl,
depth,
normalizedUrl,
});
});
if (Option.isSome(taskOption)) {
yield* Ref.update(pendingTasks, (count) => count + 1);
yield* Queue.offer(taskQueue, taskOption.value);
}
});
const markTaskComplete = (): Effect.Effect<void, never> =>
Effect.gen(function* () {
const remaining = yield* Ref.updateAndGet(pendingTasks, (count) => Math.max(0, count - 1));
if (remaining === 0) {
yield* signalShutdown();
}
});
const workerLoop = (page: Page, workerId: number): Effect.Effect<void, CaptureError | FileSystemError> =>
Effect.gen(function* () {
while (true) {
const task = yield* Queue.take(taskQueue);
if (task.type === 'shutdown') {
return yield* Effect.void;
}
yield* processRouteTask(page, task, results, scheduleRoute, `#${workerId}`).pipe(
Effect.catchAll((error) => {
console.error(`[Worker ${workerId}] Failed to capture ${task.url}:`, error);
return Effect.void;
}),
Effect.ensuring(markTaskComplete())
);
}
});
const createWorker = (workerId: number): Effect.Effect<void, CaptureError | FileSystemError> =>
Effect.acquireUseRelease(
Effect.gen(function* () {
if (!browser) {
return yield* Effect.fail(new CaptureError({
url,
message: 'Browser not initialized',
cause: null,
}));
}
const context = yield* Effect.tryPromise({
try: () => browser!.newContext(),
catch: (error) => new CaptureError({
url,
message: `Worker ${workerId}: Failed to create context`,
cause: error,
}),
});
const page = yield* Effect.tryPromise({
try: () => context.newPage(),
catch: (error) => new CaptureError({
url,
message: `Worker ${workerId}: Failed to create page`,
cause: error,
}),
});
console.log(`✓ Worker ${workerId} ready`);
return { context, page };
}),
({ page }) => workerLoop(page, workerId),
({ context }) =>
Effect.tryPromise({
try: () => context.close(),
catch: () => undefined,
}).pipe(Effect.catchAll(() => Effect.void))
);
yield* scheduleRoute(url, 0);
const initialPending = yield* Ref.get(pendingTasks);
if (initialPending === 0) {
yield* signalShutdown();
}
const workers = Array.from({ length: cfg.routeConcurrency }, (_, idx) =>
createWorker(idx + 1)
);
yield* Effect.all(workers, { concurrency: cfg.routeConcurrency });
yield* Queue.shutdown(taskQueue);
yield* generateReports(results);
console.log(`\n✓ Capture completed! Results saved to: ${cfg.outputDir}`);
}),
() => cleanup.pipe(Effect.orDie)
);
return results;
});
return { captureWebsite, captureVideo } as const;
}),
}) {}
// --- Layer for providing configuration ---
/**
* Convenience layer helper for injecting a `CaptureConfig` into Effect programs.
*/
export const CaptureConfigLive = (config?: CaptureConfig | CaptureConfigOverrides) =>
Layer.succeed(
CaptureConfigTag,
config instanceof CaptureConfig ? config : createCaptureConfig(config)
);
// --- Example Usage ---
const program = Effect.gen(function* () {
const service = yield* UICaptureService;
return yield* service.captureWebsite('http://localhost:3000');
}).pipe(
Effect.provide(UICaptureService.Default),
Effect.provide(CaptureConfigLive({
captureVideo: true,
videoOptions: { duration: 15000, interactions: true },
viewports: [
{ name: 'desktop', width: 1920, height: 1080 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 390, height: 844 },
],
}))
);
// Uncomment to run
Effect.runPromise(program).catch(console.error);
export default UICaptureService;

ui-capture.ts

File Type: TS
Lines: 1161
Size: 39 KB
Generated: 11/19/2025, 3:57:50 AM


This file, ui-capture.ts, defines the core architecture and data models for a sophisticated, automated Web UI screenshot and video capture tool. It is built entirely using the Effect-TS framework, leveraging its capabilities for type safety, structured concurrency, and robust dependency management.

Technical Analysis

1. Architectural Foundation: Effect-TS

The primary technical characteristic of this codebase is its deep integration with the Effect-TS ecosystem. This choice dictates the architecture, ensuring high levels of type safety, composability, and testability.

  • Functional Core: All operations are intended to be encapsulated within Effect computations, managing side effects (I/O, browser interaction) explicitly.
  • Dependency Injection: The CaptureConfigTag utilizes Effect's Context.Tag system, allowing the configuration (CaptureConfig) to be injected immutably into the UICaptureService logic, promoting modularity and easy testing via environment overrides.
  • Structured Concurrency: The use of Queue (QueueTask, RouteTask, ShutdownTask) combined with the routeConcurrency setting in the configuration strongly suggests a structured worker pool pattern. This allows the application to efficiently crawl and capture multiple routes in parallel while managing resource limits (e.g., limiting the number of concurrent Playwright browser pages).
  • Typed Error Handling: The definition of custom, tagged error types (BrowserError, CaptureError, FileSystemError) using S.TaggedError ensures that all potential failure modes are explicitly typed and handled, eliminating untyped exceptions common in standard JavaScript/TypeScript.

2. Data Modeling and Validation (Schema)

The code uses @effect/schema extensively to define all data structures as immutable, validated classes (S.Class). This provides runtime validation and compile-time guarantees for configuration and results.

Class Purpose Key Fields
CaptureConfig Global operational settings. outputDir, routeConcurrency, maxDepth, viewports, allowedHosts.
ViewportConfig Defines responsive testing dimensions. name, width, height.
VideoOptions Defines video recording parameters. duration, interactions.
CaptureResult Output data for a single captured route. url, screenshots, videos, error.
CaptureReport Summary statistics and aggregated results for the entire run. totalRoutes, successfulCaptures, failedCaptures.

The createCaptureConfig helper function is crucial for practical usage, providing a clean mechanism to merge partial user overrides (CaptureConfigOverrides) with sensible defaults, ensuring the resulting configuration object is always a fully validated CaptureConfig instance.

3. Core Functionality and Dependencies

The application's core function is UI automation, relying on the following external libraries:

  1. Playwright: Imported via playwright (Browser, chromium, Page). This is the engine responsible for launching the browser, navigating routes, setting viewports, and performing the actual screenshot and video capture operations.
  2. Node.js Modules: node:fs/promises and node:path are used for file system operations (creating directories, saving captures), which are likely wrapped within Effect computations to handle I/O errors gracefully (e.g., resulting in a FileSystemError).

The service is designed to handle complex tasks:

  • Route Discovery: Implied by maxDepth and the use of RouteTask, the tool likely crawls a starting URL, discovering new links up to a specified depth, respecting allowedHosts and includeSubdomains.
  • Multi-Viewport Capture: It supports capturing screenshots across multiple defined viewports concurrently, essential for responsive design testing.
  • Conditional Video Recording: The captureVideo flag and VideoOptions allow for controlled recording of user interactions or page loads.

4. Service Definition and Initialization

The UICaptureService is the central business logic container. The snippet shows the start of its implementation:

export class UICaptureService extends Effect.Service<UICaptureService>()("UICaptureService", {
  effect: Effect.gen(function* () {
    const cfg = yield* CaptureConfigTag;
    let browser: Browser | null = null;
    const processedRoutes = new Set<string>();
    // ... initialization logic

This structure confirms that the service:

  1. Retrieves its configuration (cfg) via dependency injection.
  2. Manages mutable state internally (e.g., browser, processedRoutes, allowedHostnames), likely using Effect's Ref primitive (or standard mutable variables within the generator scope) to ensure state changes are managed safely within the Effect runtime.
  3. The initialization phase involves setting up the Playwright browser instance (browser) and preparing route tracking structures (processedRoutes, host sets).

5. Practical Usage and Extensibility

The design is highly practical for integration into CI/CD pipelines or automated testing frameworks:

  • Configuration Flexibility: The CaptureConfigOverrides type allows users to easily customize the run (e.g., change output directory, adjust concurrency, or define specific viewports) without needing to manually construct complex objects.
  • Clear Reporting: The CaptureReport schema ensures that the output of the tool is structured, machine-readable (e.g., JSON), and contains all necessary metrics (success rates, timestamps).
  • Extensibility: By defining the core logic as an Effect Service and using Layers (implied by the Effect architecture), developers can easily swap out dependencies (e.g., use a different browser engine or storage backend) without modifying the core capture logic.

Description generated using AI analysis

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment