diff --git a/examples/video-recording-advanced.ts b/examples/video-recording-advanced.ts new file mode 100644 index 00000000..37362712 --- /dev/null +++ b/examples/video-recording-advanced.ts @@ -0,0 +1,96 @@ +/** + * Advanced Video Recording Demo + * + * Demonstrates advanced video recording features: + * - Custom resolution (1080p) + * - Custom output filename + * - Multiple recordings in one session + */ + +import { SentienceBrowser } from '../src/browser'; +import * as path from 'path'; +import * as fs from 'fs'; + +async function recordWithCustomSettings() { + console.log('\n' + '='.repeat(60)); + console.log('Advanced Video Recording Demo'); + console.log('='.repeat(60) + '\n'); + + const videoDir = path.join(process.cwd(), 'recordings'); + + // Example 1: Custom Resolution (1080p) + console.log('šŸ“¹ Example 1: Recording in 1080p (Full HD)\n'); + + const browser1 = new SentienceBrowser( + undefined, + undefined, + false, + undefined, + undefined, + undefined, + videoDir, + { width: 1920, height: 1080 } // 1080p resolution + ); + + await browser1.start(); + console.log(' Resolution: 1920x1080'); + + const page1 = browser1.getPage(); + await page1.goto('https://example.com'); + await page1.waitForTimeout(2000); + + // Close with custom filename + const video1 = await browser1.close(path.join(videoDir, 'example_1080p.webm')); + console.log(` āœ… Saved: ${video1}\n`); + + // Example 2: Custom Filename with Timestamp + console.log('šŸ“¹ Example 2: Recording with timestamp filename\n'); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const customFilename = `recording_${timestamp}.webm`; + + const browser2 = new SentienceBrowser( + undefined, undefined, false, undefined, undefined, undefined, + videoDir + ); + + await browser2.start(); + + const page2 = browser2.getPage(); + await page2.goto('https://example.com'); + await page2.click('text=More information'); + await page2.waitForTimeout(2000); + + const video2 = await browser2.close(path.join(videoDir, customFilename)); + console.log(` āœ… Saved: ${video2}\n`); + + // Example 3: Organized by Project + console.log('šŸ“¹ Example 3: Organized directory structure\n'); + + const projectDir = path.join(videoDir, 'my_project', 'tutorials'); + const browser3 = new SentienceBrowser( + undefined, undefined, false, undefined, undefined, undefined, + projectDir + ); + + await browser3.start(); + console.log(` Saving to: ${projectDir}`); + + const page3 = browser3.getPage(); + await page3.goto('https://example.com'); + await page3.waitForTimeout(2000); + + const video3 = await browser3.close(path.join(projectDir, 'tutorial_01.webm')); + console.log(` āœ… Saved: ${video3}\n`); + + console.log('='.repeat(60)); + console.log('All recordings completed!'); + console.log(`Check ${path.resolve(videoDir)} for all videos`); + console.log('='.repeat(60) + '\n'); +} + +// Run the demo +recordWithCustomSettings().catch(error => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/examples/video-recording-demo.ts b/examples/video-recording-demo.ts new file mode 100644 index 00000000..b005952a --- /dev/null +++ b/examples/video-recording-demo.ts @@ -0,0 +1,70 @@ +/** + * Video Recording Demo - Record browser sessions with SentienceBrowser + * + * This example demonstrates how to use the video recording feature + * to capture browser automation sessions. + */ + +import { SentienceBrowser } from '../src/browser'; +import * as path from 'path'; +import * as fs from 'fs'; + +async function main() { + // Create output directory for videos + const videoDir = path.join(process.cwd(), 'recordings'); + if (!fs.existsSync(videoDir)) { + fs.mkdirSync(videoDir, { recursive: true }); + } + + console.log('\n' + '='.repeat(60)); + console.log('Video Recording Demo'); + console.log('='.repeat(60) + '\n'); + + // Create browser with video recording enabled + const browser = new SentienceBrowser( + undefined, // apiKey + undefined, // apiUrl + false, // headless - set to false so you can see the recording + undefined, // proxy + undefined, // userDataDir + undefined, // storageState + videoDir // recordVideoDir - enables video recording + ); + + await browser.start(); + console.log('šŸŽ„ Video recording enabled'); + console.log(`šŸ“ Videos will be saved to: ${path.resolve(videoDir)}\n`); + + try { + const page = browser.getPage(); + + // Navigate to example.com + console.log('Navigating to example.com...'); + await page.goto('https://example.com'); + await page.waitForLoadState('networkidle'); + + // Perform some actions + console.log('Taking screenshot...'); + await page.screenshot({ path: 'example_screenshot.png' }); + + console.log('Scrolling page...'); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(1000); + + console.log('\nāœ… Recording complete!'); + console.log('Video will be saved when browser closes...\n'); + } finally { + // Video is automatically saved when browser closes + const videoPath = await browser.close(); + console.log('='.repeat(60)); + console.log(`Video saved to: ${videoPath}`); + console.log(`Check ${path.resolve(videoDir)} for the recorded video (.webm)`); + console.log('='.repeat(60) + '\n'); + } +} + +// Run the demo +main().catch(error => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/package.json b/package.json index bf4aed81..b9101733 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sentienceapi", - "version": "0.90.11", + "version": "0.90.12", "description": "TypeScript SDK for Sentience AI Agent Browser Automation", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/browser.ts b/src/browser.ts index c03d56fd..160b74a0 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -21,6 +21,8 @@ export class SentienceBrowser { private _proxy?: string; private _userDataDir?: string; private _storageState?: string | StorageState | object; + private _recordVideoDir?: string; + private _recordVideoSize?: { width: number; height: number }; constructor( apiKey?: string, @@ -28,7 +30,9 @@ export class SentienceBrowser { headless?: boolean, proxy?: string, userDataDir?: string, - storageState?: string | StorageState | object + storageState?: string | StorageState | object, + recordVideoDir?: string, + recordVideoSize?: { width: number; height: number } ) { this._apiKey = apiKey; @@ -54,6 +58,10 @@ export class SentienceBrowser { // Auth injection support this._userDataDir = userDataDir; this._storageState = storageState; + + // Video recording support + this._recordVideoDir = recordVideoDir; + this._recordVideoSize = recordVideoSize || { width: 1280, height: 800 }; } async start(): Promise { @@ -129,8 +137,17 @@ export class SentienceBrowser { // 4. Parse proxy configuration const proxyConfig = this.parseProxy(this._proxy); - // 5. Launch Browser - this.context = await chromium.launchPersistentContext(this.userDataDir, { + // 5. Setup video recording directory if requested + if (this._recordVideoDir) { + if (!fs.existsSync(this._recordVideoDir)) { + fs.mkdirSync(this._recordVideoDir, { recursive: true }); + } + console.log(`šŸŽ„ [Sentience] Recording video to: ${this._recordVideoDir}`); + console.log(` Resolution: ${this._recordVideoSize!.width}x${this._recordVideoSize!.height}`); + } + + // 6. Launch Browser + const launchOptions: any = { headless: false, // Must be false here, handled via args above args: args, viewport: { width: 1920, height: 1080 }, @@ -139,7 +156,17 @@ export class SentienceBrowser { proxy: proxyConfig, // Pass proxy configuration // CRITICAL: Ignore HTTPS errors when using proxy (proxies often use self-signed certs) ignoreHTTPSErrors: proxyConfig !== undefined - }); + }; + + // Add video recording if configured + if (this._recordVideoDir) { + launchOptions.recordVideo = { + dir: this._recordVideoDir, + size: this._recordVideoSize + }; + } + + this.context = await chromium.launchPersistentContext(this.userDataDir, launchOptions); this.page = this.context.pages()[0] || await this.context.newPage(); @@ -622,10 +649,46 @@ export class SentienceBrowser { return this.context; } - async close(): Promise { + async close(outputPath?: string): Promise { + let tempVideoPath: string | null = null; + + // Get video path before closing (if recording was enabled) + // Note: Playwright saves videos when pages/context close, but we can get the + // expected path before closing. The actual file will be available after close. + if (this._recordVideoDir) { + try { + // Try to get video path from the first page + if (this.page) { + const video = this.page.video(); + if (video) { + tempVideoPath = await video.path(); + } + } + // If that fails, check all pages in the context (before closing) + if (!tempVideoPath && this.context) { + const pages = this.context.pages(); + for (const page of pages) { + try { + const video = page.video(); + if (video) { + tempVideoPath = await video.path(); + break; + } + } catch { + // Continue to next page + } + } + } + } catch { + // Video path might not be available until after close + // We'll use fallback mechanism below + } + } + const cleanup: Promise[] = []; - + // Close context first (this also closes the browser for persistent contexts) + // This triggers video file finalization if (this.context) { cleanup.push( this.context.close().catch(() => { @@ -634,7 +697,7 @@ export class SentienceBrowser { ); this.context = null; } - + // Close browser if it exists (for non-persistent contexts) if (this.browser) { cleanup.push( @@ -647,7 +710,7 @@ export class SentienceBrowser { // Wait for all cleanup to complete await Promise.all(cleanup); - + // Clean up extension directory if (this.extensionPath && fs.existsSync(this.extensionPath)) { try { @@ -657,7 +720,47 @@ export class SentienceBrowser { } this.extensionPath = null; } - + + // After context closes, verify video file exists if we have a path + let finalPath = tempVideoPath; + if (tempVideoPath && fs.existsSync(tempVideoPath)) { + // Video file exists, proceed with rename if needed + } else if (this._recordVideoDir && fs.existsSync(this._recordVideoDir)) { + // Fallback: If we couldn't get the path but recording was enabled, + // check the directory for video files + try { + const videoFiles = fs.readdirSync(this._recordVideoDir) + .filter(f => f.endsWith('.webm')) + .map(f => ({ + path: path.join(this._recordVideoDir!, f), + mtime: fs.statSync(path.join(this._recordVideoDir!, f)).mtime.getTime() + })) + .sort((a, b) => b.mtime - a.mtime); // Most recent first + + if (videoFiles.length > 0) { + finalPath = videoFiles[0].path; + } + } catch { + // Ignore errors when scanning directory + } + } + + // Rename/move video if output_path is specified + if (finalPath && outputPath && fs.existsSync(finalPath)) { + try { + // Ensure parent directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + fs.renameSync(finalPath, outputPath); + finalPath = outputPath; + } catch (error: any) { + console.warn(`Failed to rename video file: ${error.message}`); + // Return original path if rename fails + } + } + // Clean up user data directory (only if it's a temp directory) // If user provided a custom userDataDir, we don't delete it (persistent sessions) if (this.userDataDir && fs.existsSync(this.userDataDir)) { @@ -672,5 +775,7 @@ export class SentienceBrowser { } this.userDataDir = null; } + + return finalPath; } } \ No newline at end of file diff --git a/tests/video-recording.test.ts b/tests/video-recording.test.ts new file mode 100644 index 00000000..f9877afe --- /dev/null +++ b/tests/video-recording.test.ts @@ -0,0 +1,265 @@ +/** + * Tests for video recording functionality + */ + +import { SentienceBrowser } from '../src'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +describe('video recording', () => { + let tempDir: string; + + beforeEach(() => { + // Create a temporary directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'video-test-')); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should record video with basic setup', async () => { + const videoDir = path.join(tempDir, 'recordings'); + + const browser = new SentienceBrowser( + undefined, + undefined, + true, // headless + undefined, + undefined, + undefined, + videoDir + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(); + + // Verify video was created + expect(videoPath).toBeTruthy(); + expect(videoPath).toMatch(/\.webm$/); + expect(fs.existsSync(videoPath!)).toBe(true); + + // Verify file has content + const stats = fs.statSync(videoPath!); + expect(stats.size).toBeGreaterThan(0); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should record video with custom resolution', async () => { + const videoDir = path.join(tempDir, 'recordings'); + + const browser = new SentienceBrowser( + undefined, + undefined, + true, + undefined, + undefined, + undefined, + videoDir, + { width: 1920, height: 1080 } + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(); + + expect(videoPath).toBeTruthy(); + expect(fs.existsSync(videoPath!)).toBe(true); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should rename video to custom output path', async () => { + const videoDir = path.join(tempDir, 'recordings'); + const customPath = path.join(videoDir, 'my_recording.webm'); + + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + videoDir + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(customPath); + + // Verify video was renamed to custom path + expect(videoPath).toBe(customPath); + expect(fs.existsSync(customPath)).toBe(true); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should create nested directories for output path', async () => { + const videoDir = path.join(tempDir, 'recordings'); + const nestedPath = path.join(videoDir, 'project', 'tutorials', 'video1.webm'); + + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + videoDir + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(nestedPath); + + // Verify nested directories were created + expect(videoPath).toBe(nestedPath); + expect(fs.existsSync(nestedPath)).toBe(true); + expect(fs.existsSync(path.dirname(nestedPath))).toBe(true); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should return null when recording is disabled', async () => { + const browser = new SentienceBrowser(undefined, undefined, true); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(); + + // Should return null when recording is disabled + expect(videoPath).toBeNull(); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should auto-create video directory', async () => { + // Use a non-existent directory + const videoDir = path.join(tempDir, 'new_recordings', 'subdir'); + + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + videoDir + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(); + + // Verify directory was created + expect(fs.existsSync(videoDir)).toBe(true); + expect(videoPath).toBeTruthy(); + expect(fs.existsSync(videoPath!)).toBe(true); + } catch (error) { + await browser.close(); + throw error; + } + }); + + it('should create multiple video recordings in sequence', async () => { + const videoDir = path.join(tempDir, 'recordings'); + const videoPaths: string[] = []; + + // Create 3 video recordings + for (let i = 0; i < 3; i++) { + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + videoDir + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const outputPath = path.join(videoDir, `video_${i}.webm`); + const videoPath = await browser.close(outputPath); + + expect(videoPath).toBe(outputPath); + videoPaths.push(videoPath!); + } catch (error) { + await browser.close(); + throw error; + } + } + + // Verify all videos were created + for (const videoPath of videoPaths) { + expect(fs.existsSync(videoPath)).toBe(true); + } + }); + + it('should use default resolution of 1280x800', () => { + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + path.join(tempDir, 'recordings') + ); + + // Verify default resolution + expect(browser['_recordVideoSize']).toEqual({ width: 1280, height: 800 }); + }); + + it('should handle video recording with various resolutions', async () => { + const resolutions = [ + { width: 1280, height: 720 }, // 720p + { width: 1920, height: 1080 }, // 1080p + { width: 2560, height: 1440 }, // 1440p + ]; + + for (const resolution of resolutions) { + const videoDir = path.join(tempDir, `recordings_${resolution.width}x${resolution.height}`); + + const browser = new SentienceBrowser( + undefined, undefined, true, undefined, undefined, undefined, + videoDir, + resolution + ); + + await browser.start(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const videoPath = await browser.close(); + + expect(videoPath).toBeTruthy(); + expect(fs.existsSync(videoPath!)).toBe(true); + } catch (error) { + await browser.close(); + throw error; + } + } + }); +});