diff --git a/README.md b/README.md index 7e2dae78..a191cda4 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,19 @@ vizzly tdd run "npm test" ```javascript import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; -// Your test framework takes the screenshot +// Option 1: Using a Buffer const screenshot = await page.screenshot(); - -// Send to Vizzly for review await vizzlyScreenshot('homepage', screenshot, { browser: 'chrome', viewport: '1920x1080' }); + +// Option 2: Using a file path +await page.screenshot({ path: './screenshots/homepage.png' }); +await vizzlyScreenshot('homepage', './screenshots/homepage.png', { + browser: 'chrome', + viewport: '1920x1080' +}); ``` > **Multi-Language Support**: Currently available as a JavaScript/Node.js SDK with Python, Ruby, and @@ -360,9 +365,14 @@ The `--wait` flag ensures the process: ### `vizzlyScreenshot(name, imageBuffer, properties)` Send a screenshot to Vizzly. - `name` (string): Screenshot identifier -- `imageBuffer` (Buffer): Image data +- `imageBuffer` (Buffer | string): Image data as Buffer, or file path to an image - `properties` (object): Metadata for organization +**File Path Support:** +- Accepts both absolute and relative paths +- Automatically reads the file and converts to Buffer internally +- Works with any PNG image file + ### `isVizzlyEnabled()` Check if Vizzly is enabled in the current environment. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a87488a..34635762 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -12,7 +12,7 @@ Capture a screenshot for visual regression testing. **Parameters:** - `name` (string) - Unique screenshot identifier -- `imageBuffer` (Buffer) - PNG image data as Buffer +- `imageBuffer` (Buffer | string) - PNG image data as Buffer, or file path to an image - `options` (object, optional) - Configuration and metadata **Options:** @@ -20,7 +20,7 @@ Capture a screenshot for visual regression testing. { // Comparison settings threshold: 0.01, // Pixel difference threshold (0-1) - + // Metadata for organization (all optional) properties: { browser: 'chrome', // Browser name @@ -39,7 +39,10 @@ Capture a screenshot for visual regression testing. **Returns:** `Promise` -**Example:** +**Examples:** + +Using a Buffer: + ```javascript import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; @@ -54,6 +57,31 @@ await vizzlyScreenshot('homepage', screenshot, { }); ``` +Using a file path: + +```javascript +import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; + +// Save screenshot to file +await page.screenshot({ path: './screenshots/homepage.png' }); + +// Send to Vizzly using file path +await vizzlyScreenshot('homepage', './screenshots/homepage.png', { + threshold: 0.02, + properties: { + browser: 'chrome', + viewport: '1920x1080', + component: 'hero-section' + } +}); +``` + +**File Path Support:** +- Accepts both absolute and relative paths +- Automatically reads the file and converts to Buffer internally +- Throws error if file doesn't exist or cannot be read +- Works with any PNG image file + ### `vizzlyFlush()` Wait for all queued screenshots to be processed. @@ -201,10 +229,24 @@ Stop the Vizzly server and cleanup resources. **Returns:** `Promise` ##### `screenshot(name, imageBuffer, options)` -Capture a screenshot (same as client API). +Capture a screenshot. + +**Parameters:** +- `name` (string) - Unique screenshot identifier +- `imageBuffer` (Buffer | string) - PNG image data as Buffer, or file path to an image +- `options` (object, optional) - Configuration and metadata **Returns:** `Promise` +**Example:** +```javascript +// Using a Buffer +await vizzly.screenshot('homepage', buffer); + +// Using a file path +await vizzly.screenshot('homepage', './screenshots/homepage.png'); +``` + ##### `upload(options)` Upload screenshots to Vizzly. @@ -224,8 +266,21 @@ Upload screenshots to Vizzly. ##### `compare(name, imageBuffer)` Run local comparison (TDD mode). +**Parameters:** +- `name` (string) - Screenshot name +- `imageBuffer` (Buffer | string) - PNG image data as Buffer, or file path to an image + **Returns:** `Promise` +**Example:** +```javascript +// Using a Buffer +const result = await vizzly.compare('homepage', buffer); + +// Using a file path +const result = await vizzly.compare('homepage', './screenshots/homepage.png'); +``` + ##### `getConfig()` Get current SDK configuration. diff --git a/docs/test-integration.md b/docs/test-integration.md index 7c7ad242..638bef41 100644 --- a/docs/test-integration.md +++ b/docs/test-integration.md @@ -64,7 +64,9 @@ The CLI automatically sets these variables for your test process: ## Adding Screenshots to Tests -Import the client and use `vizzlyScreenshot()` in your tests: +Import the client and use `vizzlyScreenshot()` in your tests. You can pass either a **Buffer** or a **file path**: + +### Using a Buffer ```javascript import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; @@ -81,6 +83,28 @@ await vizzlyScreenshot('homepage', screenshot, { }); ``` +### Using a File Path + +```javascript +import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; + +// Save screenshot to file first +await page.screenshot({ path: './screenshots/homepage.png' }); + +// Send to Vizzly using file path +await vizzlyScreenshot('homepage', './screenshots/homepage.png', { + properties: { + browser: 'chrome', + viewport: '1920x1080' + } +}); +``` + +**File path support:** +- Accepts both absolute and relative paths +- Works with any tool that generates PNG files +- Useful when screenshots are already saved to disk + ## Framework Examples ### Playwright @@ -91,7 +115,8 @@ import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; test('homepage test', async ({ page }) => { await page.goto('/'); - + + // Using a Buffer const screenshot = await page.screenshot(); await vizzlyScreenshot('homepage', screenshot, { properties: { @@ -100,6 +125,16 @@ test('homepage test', async ({ page }) => { page: 'home' } }); + + // Using a file path + await page.screenshot({ path: './screenshots/homepage.png' }); + await vizzlyScreenshot('homepage', './screenshots/homepage.png', { + properties: { + browser: 'chrome', + viewport: '1920x1080', + page: 'home' + } + }); }); ``` @@ -108,20 +143,25 @@ test('homepage test', async ({ page }) => { ```javascript // cypress/support/commands.js import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; +import { join } from 'path'; +// Using file paths Cypress.Commands.add('vizzlyScreenshot', (name, properties = {}) => { + // Cypress saves screenshots to cypress/screenshots by default cy.screenshot(name, { capture: 'viewport' }); - - cy.readFile(`cypress/screenshots/${name}.png`, 'base64').then((imageBase64) => { - const imageBuffer = Buffer.from(imageBase64, 'base64'); - return vizzlyScreenshot(name, imageBuffer, { + + // Use file path directly + const screenshotPath = join(Cypress.config('screenshotsFolder'), `${name}.png`); + + return cy.wrap( + vizzlyScreenshot(name, screenshotPath, { properties: { browser: Cypress.browser.name, framework: 'cypress', ...properties } - }); - }); + }) + ); }); // In your test @@ -147,7 +187,8 @@ describe('Visual tests', () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('/'); - + + // Using a Buffer const screenshot = await page.screenshot(); await vizzlyScreenshot('homepage', screenshot, { properties: { @@ -155,7 +196,16 @@ describe('Visual tests', () => { framework: 'puppeteer' } }); - + + // Using a file path + await page.screenshot({ path: './screenshots/homepage.png' }); + await vizzlyScreenshot('homepage', './screenshots/homepage.png', { + properties: { + browser: 'chrome', + framework: 'puppeteer' + } + }); + await browser.close(); }); }); diff --git a/src/client/index.js b/src/client/index.js index a8d89766..870d4cc6 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -9,6 +9,7 @@ import { isTddMode, setVizzlyEnabled, } from '../utils/environment-config.js'; +import { resolveImageBuffer } from '../utils/file-helpers.js'; import { existsSync, readFileSync } from 'fs'; import { join, parse, dirname } from 'path'; @@ -220,7 +221,7 @@ function createSimpleClient(serverUrl) { * Take a screenshot for visual regression testing * * @param {string} name - Unique name for the screenshot - * @param {Buffer} imageBuffer - PNG image data as a Buffer + * @param {Buffer|string} imageBuffer - PNG image data as a Buffer, or a file path to an image * @param {Object} [options] - Optional configuration * @param {Record} [options.properties] - Additional properties to attach to the screenshot * @param {number} [options.threshold=0] - Pixel difference threshold (0-100) @@ -229,13 +230,17 @@ function createSimpleClient(serverUrl) { * @returns {Promise} * * @example - * // Basic usage + * // Basic usage with Buffer * import { vizzlyScreenshot } from '@vizzly-testing/cli/client'; * * const screenshot = await page.screenshot(); * await vizzlyScreenshot('homepage', screenshot); * * @example + * // Basic usage with file path + * await vizzlyScreenshot('homepage', './screenshots/homepage.png'); + * + * @example * // With properties and threshold * await vizzlyScreenshot('checkout-form', screenshot, { * properties: { @@ -246,6 +251,8 @@ function createSimpleClient(serverUrl) { * }); * * @throws {VizzlyError} When screenshot capture fails or client is not initialized + * @throws {VizzlyError} When file path is provided but file doesn't exist + * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors */ export async function vizzlyScreenshot(name, imageBuffer, options = {}) { if (isVizzlyDisabled()) { @@ -264,7 +271,10 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) { return; } - return client.screenshot(name, imageBuffer, options); + // Resolve Buffer or file path using shared utility + const buffer = resolveImageBuffer(imageBuffer, 'screenshot'); + + return client.screenshot(name, buffer, options); } /** diff --git a/src/sdk/index.js b/src/sdk/index.js index ad06ed20..ce61fc40 100644 --- a/src/sdk/index.js +++ b/src/sdk/index.js @@ -11,6 +11,7 @@ */ import { EventEmitter } from 'events'; +import { resolveImageBuffer } from '../utils/file-helpers.js'; import { createUploader } from '../services/uploader.js'; import { createTDDService } from '../services/tdd-service.js'; import { ScreenshotServer } from '../services/screenshot-server.js'; @@ -228,9 +229,12 @@ export class VizzlySDK extends EventEmitter { /** * Capture a screenshot * @param {string} name - Screenshot name - * @param {Buffer} imageBuffer - Image data + * @param {Buffer|string} imageBuffer - Image data as a Buffer, or a file path to an image * @param {import('../types').ScreenshotOptions} [options] - Options * @returns {Promise} + * @throws {VizzlyError} When server is not running + * @throws {VizzlyError} When file path is provided but file doesn't exist + * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors */ async screenshot(name, imageBuffer, options = {}) { if (!this.server || !this.server.isRunning()) { @@ -240,12 +244,15 @@ export class VizzlySDK extends EventEmitter { ); } + // Resolve Buffer or file path using shared utility + const buffer = resolveImageBuffer(imageBuffer, 'screenshot'); + // Generate or use provided build ID const buildId = options.buildId || this.currentBuildId || 'default'; this.currentBuildId = buildId; // Convert Buffer to base64 for JSON transport - const imageBase64 = imageBuffer.toString('base64'); + const imageBase64 = buffer.toString('base64'); const screenshotData = { buildId, @@ -346,8 +353,10 @@ export class VizzlySDK extends EventEmitter { /** * Run local comparison in TDD mode * @param {string} name - Screenshot name - * @param {Buffer} imageBuffer - Current image + * @param {Buffer|string} imageBuffer - Current image as a Buffer, or a file path to an image * @returns {Promise} Comparison result + * @throws {VizzlyError} When file path is provided but file doesn't exist + * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors */ async compare(name, imageBuffer) { if (!this.services?.tddService) { @@ -357,10 +366,13 @@ export class VizzlySDK extends EventEmitter { }); } + // Resolve Buffer or file path using shared utility + const buffer = resolveImageBuffer(imageBuffer, 'compare'); + try { const result = await this.services.tddService.compareScreenshot( name, - imageBuffer + buffer ); this.emit('comparison:completed', result); return result; diff --git a/src/utils/file-helpers.js b/src/utils/file-helpers.js new file mode 100644 index 00000000..a2252c8e --- /dev/null +++ b/src/utils/file-helpers.js @@ -0,0 +1,69 @@ +/** + * @module file-helpers + * @description Utilities for handling file-based screenshot inputs + */ + +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { VizzlyError } from '../errors/vizzly-error.js'; + +/** + * Resolve image buffer from file path or return buffer as-is + * Handles both Buffer inputs and file path strings, with proper validation and error handling + * + * @param {Buffer|string} imageBufferOrPath - Image data as Buffer or file path + * @param {string} contextName - Context for error messages (e.g., 'screenshot', 'compare') + * @returns {Buffer} The image buffer + * @throws {VizzlyError} When file not found, unreadable, or invalid input type + * + * @example + * // With Buffer + * const buffer = resolveImageBuffer(myBuffer, 'screenshot'); + * + * @example + * // With file path + * const buffer = resolveImageBuffer('./my-image.png', 'screenshot'); + */ +export function resolveImageBuffer(imageBufferOrPath, contextName) { + // Return Buffer as-is + if (Buffer.isBuffer(imageBufferOrPath)) { + return imageBufferOrPath; + } + + // Validate input type + if (typeof imageBufferOrPath !== 'string') { + throw new VizzlyError( + `Invalid image input: expected Buffer or file path string`, + 'INVALID_INPUT', + { contextName, type: typeof imageBufferOrPath } + ); + } + + // Resolve to absolute path for consistent behavior + const filePath = resolve(imageBufferOrPath); + + // Check file exists + if (!existsSync(filePath)) { + throw new VizzlyError( + `Screenshot file not found: ${imageBufferOrPath}`, + 'FILE_NOT_FOUND', + { contextName, filePath, originalPath: imageBufferOrPath } + ); + } + + // Read file with error handling + try { + return readFileSync(filePath); + } catch (error) { + throw new VizzlyError( + `Failed to read screenshot file: ${imageBufferOrPath} - ${error.message}`, + 'FILE_READ_ERROR', + { + contextName, + filePath, + originalPath: imageBufferOrPath, + originalError: error.message, + } + ); + } +} diff --git a/tests/sdk/sdk-core.spec.js b/tests/sdk/sdk-core.spec.js index 6dd69dfc..307e9351 100644 --- a/tests/sdk/sdk-core.spec.js +++ b/tests/sdk/sdk-core.spec.js @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createVizzly, VizzlySDK } from '../../src/sdk/index.js'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; // Mock all dependencies with simple mocks vi.mock('../../src/services/uploader.js'); @@ -360,6 +362,135 @@ describe('Vizzly SDK Core Functionality', () => { }); }); + describe('File Path Support', () => { + let sdk; + let testDir; + let testImagePath; + + beforeEach(() => { + // Create test directory and test image file + testDir = join(process.cwd(), 'tests', 'fixtures', 'temp-screenshots'); + mkdirSync(testDir, { recursive: true }); + + testImagePath = join(testDir, 'test-screenshot.png'); + const testImageBuffer = Buffer.from('fake-png-data'); + writeFileSync(testImagePath, testImageBuffer); + + sdk = new VizzlySDK( + { server: { port: 3000 } }, + { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + {} + ); + sdk.server = { isRunning: () => true }; + + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + }); + + afterEach(() => { + // Clean up test files + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should accept file path and read screenshot from file', async () => { + await sdk.screenshot('test-from-file', testImagePath); + + const expectedBase64 = Buffer.from('fake-png-data').toString('base64'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/screenshot', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining(`"image":"${expectedBase64}"`), + }) + ); + }); + + it('should accept relative file paths', async () => { + const relativePath = join( + 'tests', + 'fixtures', + 'temp-screenshots', + 'test-screenshot.png' + ); + + await sdk.screenshot('test-relative-path', relativePath); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/screenshot', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('should throw error for non-existent file', async () => { + const nonExistentPath = join(testDir, 'does-not-exist.png'); + const { VizzlyError } = await import('../../src/errors/vizzly-error.js'); + + await expect( + sdk.screenshot('test-missing-file', nonExistentPath) + ).rejects.toThrow(VizzlyError); + + await expect( + sdk.screenshot('test-missing-file', nonExistentPath) + ).rejects.toThrow(/Screenshot file not found/); + }); + + it('should maintain backward compatibility with Buffer', async () => { + const imageBuffer = Buffer.from('direct-buffer-data'); + + await sdk.screenshot('test-buffer', imageBuffer); + + const expectedBase64 = imageBuffer.toString('base64'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/screenshot', + expect.objectContaining({ + body: expect.stringContaining(`"image":"${expectedBase64}"`), + }) + ); + }); + + it('should support file paths in compare method', async () => { + const mockCompareScreenshot = vi.fn().mockResolvedValue({ + status: 'passed', + diffPercentage: 0, + }); + + // Set up the tddService mock on the SDK instance + sdk.services = { + tddService: { + compareScreenshot: mockCompareScreenshot, + }, + }; + + await sdk.compare('test-compare-file', testImagePath); + + expect(mockCompareScreenshot).toHaveBeenCalledWith( + 'test-compare-file', + expect.any(Buffer) + ); + }); + + it('should throw error for missing file in compare method', async () => { + const nonExistentPath = join(testDir, 'does-not-exist.png'); + const { VizzlyError } = await import('../../src/errors/vizzly-error.js'); + + await expect( + sdk.compare('test-compare-missing', nonExistentPath) + ).rejects.toThrow(VizzlyError); + + await expect( + sdk.compare('test-compare-missing', nonExistentPath) + ).rejects.toThrow(/Screenshot file not found/); + }); + }); + describe('SDK Error Handling', () => { it('should preserve VizzlyError types during screenshot failures', async () => { const sdk = new VizzlySDK({}, {}, {}); diff --git a/tests/unit/client-file-path.spec.js b/tests/unit/client-file-path.spec.js new file mode 100644 index 00000000..88777fe5 --- /dev/null +++ b/tests/unit/client-file-path.spec.js @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; + +// Mock fetch globally +global.fetch = vi.fn(); + +describe('Client SDK - File Path Support', () => { + let testDir; + let testImagePath; + let vizzlyScreenshot; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Create test directory and test image file + testDir = join( + process.cwd(), + 'tests', + 'fixtures', + 'temp-client-screenshots' + ); + mkdirSync(testDir, { recursive: true }); + + testImagePath = join(testDir, 'test-screenshot.png'); + const testImageBuffer = Buffer.from('fake-png-data'); + writeFileSync(testImagePath, testImageBuffer); + + // Mock server.json for auto-discovery + const vizzlyDir = join(process.cwd(), '.vizzly'); + mkdirSync(vizzlyDir, { recursive: true }); + writeFileSync( + join(vizzlyDir, 'server.json'), + JSON.stringify({ port: 47392 }) + ); + + // Import fresh module + const clientModule = await import('../../src/client/index.js'); + vizzlyScreenshot = clientModule.vizzlyScreenshot; + + // Mock successful response + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + }); + + afterEach(() => { + // Clean up test files + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + + try { + rmSync(join(process.cwd(), '.vizzly'), { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + + vi.resetModules(); + }); + + it('should accept file path and read screenshot from file', async () => { + await vizzlyScreenshot('test-from-file', testImagePath); + + const expectedBase64 = Buffer.from('fake-png-data').toString('base64'); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:47392/screenshot', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining(`"image":"${expectedBase64}"`), + }) + ); + }); + + it('should accept relative file paths', async () => { + const relativePath = join( + 'tests', + 'fixtures', + 'temp-client-screenshots', + 'test-screenshot.png' + ); + + await vizzlyScreenshot('test-relative-path', relativePath); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/screenshot'), + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('should throw error for non-existent file', async () => { + const nonExistentPath = join(testDir, 'does-not-exist.png'); + + await expect( + vizzlyScreenshot('test-missing-file', nonExistentPath) + ).rejects.toThrow(/Screenshot file not found/); + }); + + it('should maintain backward compatibility with Buffer', async () => { + const imageBuffer = Buffer.from('direct-buffer-data'); + + await vizzlyScreenshot('test-buffer', imageBuffer); + + const expectedBase64 = imageBuffer.toString('base64'); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/screenshot'), + expect.objectContaining({ + body: expect.stringContaining(`"image":"${expectedBase64}"`), + }) + ); + }); + + it('should pass options correctly with file path', async () => { + await vizzlyScreenshot('test-with-options', testImagePath, { + properties: { + browser: 'chrome', + viewport: { width: 1920, height: 1080 }, + }, + threshold: 5, + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/screenshot'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"browser":"chrome"'), + }) + ); + }); +});