diff --git a/src/client/index.js b/src/client/index.js index b63dc7e3..75df9138 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -13,16 +13,17 @@ import { isTddMode, setVizzlyEnabled, } from '../utils/environment-config.js'; +import { createScreenshotProperties } from '../utils/screenshot-options.js'; // Internal client state let currentClient = null; let isDisabled = false; // Default timeout for screenshot requests (30 seconds) -const DEFAULT_TIMEOUT_MS = 30000; +let DEFAULT_TIMEOUT_MS = 30000; // Log levels for client SDK output control -export const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; +export let LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; /** * Check if client should log at the given level @@ -71,16 +72,16 @@ export function autoDiscoverTddServer(startDir, deps = {}) { try { // Look for .vizzly/server.json in current directory and parent directories let currentDir = startDir || process.cwd(); - const root = parse(currentDir).root; + let root = parse(currentDir).root; while (currentDir !== root) { - const serverJsonPath = join(currentDir, '.vizzly', 'server.json'); + let serverJsonPath = join(currentDir, '.vizzly', 'server.json'); if (exists(serverJsonPath)) { try { - const serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8')); + let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8')); if (serverInfo.port) { - const url = `http://localhost:${serverInfo.port}`; + let url = `http://localhost:${serverInfo.port}`; return url; } } catch { @@ -199,23 +200,17 @@ function createSimpleClient(serverUrl) { let image = isFilePath ? imageBuffer : imageBuffer.toString('base64'); let type = isFilePath ? 'file-path' : 'base64'; - let { - fullPage, - threshold, - properties: userProperties, - ...rest - } = options; + let properties = createScreenshotProperties(options); let httpStart = Date.now(); - const { status, json } = await httpPost( + let { status, json } = await httpPost( `${serverUrl}/screenshot`, { buildId: getBuildId(), name, image, type, - properties: { ...rest, ...userProperties }, - fullPage: fullPage || false, + properties, }, DEFAULT_TIMEOUT_MS ); @@ -321,10 +316,11 @@ function createSimpleClient(serverUrl) { * @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) - * @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot + * @param {number} [options.threshold] - CIEDE2000 Delta E threshold for this screenshot + * @param {number} [options.minClusterSize] - Minimum changed cluster size for this screenshot + * @param {boolean} [options.fullPage] - Whether this is a full page screenshot * - * @returns {Promise} + * @returns {Promise} Screenshot result from the server, or null when capture is skipped * * @example * // Basic usage with Buffer @@ -347,20 +343,18 @@ function createSimpleClient(serverUrl) { * threshold: 5 * }); * - * @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 + * Capture failures are logged and return null so test suites can continue. */ export async function vizzlyScreenshot(name, imageBuffer, options = {}) { if (isVizzlyDisabled()) { - return; // Silently skip when disabled + return null; // Silently skip when disabled } let client = getClient(); if (!client) { // Silently disable - no server running, nothing to do disableVizzly(); - return; + return null; } // Pass through the original value (Buffer or file path) @@ -371,7 +365,7 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) { /** * Wait for all queued screenshots to be processed * - * @returns {Promise} + * @returns {Promise} Flush summary, or null if no server is connected * * @example * afterAll(async () => { @@ -379,10 +373,11 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) { * }); */ export async function vizzlyFlush() { - const client = getClient(); + let client = getClient(); if (client) { return client.flush(); } + return null; } /** @@ -431,7 +426,7 @@ export function setEnabled(enabled) { * @returns {Object} Client information */ export function getVizzlyInfo() { - const client = getClient(); + let client = getClient(); return { enabled: !isVizzlyDisabled(), serverUrl: getServerUrl(), diff --git a/src/sdk/index.js b/src/sdk/index.js index 61a1e5f3..850b5b58 100644 --- a/src/sdk/index.js +++ b/src/sdk/index.js @@ -11,13 +11,19 @@ */ import { EventEmitter } from 'node:events'; +import { createServer } from 'node:http'; import { VizzlyError } from '../errors/vizzly-error.js'; -import { ScreenshotServer } from '../services/screenshot-server.js'; +import { + handleRequest as handleScreenshotRequest, + startServer as startScreenshotServer, + stopServer as stopScreenshotServer, +} from '../screenshot-server/index.js'; import { createUploader } from '../services/uploader.js'; import { createTDDService } from '../tdd/tdd-service.js'; import { loadConfig } from '../utils/config-loader.js'; import { resolveImageBuffer } from '../utils/file-helpers.js'; import * as output from '../utils/output.js'; +import { createScreenshotProperties } from '../utils/screenshot-options.js'; /** * Create a new Vizzly instance with custom configuration @@ -52,76 +58,97 @@ import * as output from '../utils/output.js'; * // Cleanup * await vizzly.stop(); */ -export function createVizzly(config = {}, options = {}) { +export async function createVizzly(config = {}, options = {}) { // Configure output based on options output.configure({ verbose: options.verbose || false, }); - // Merge with loaded config - const resolvedConfig = { ...config }; - - /** - * Initialize SDK with config loading - */ - const init = async () => { - const fileConfig = await loadConfig(); - Object.assign(resolvedConfig, fileConfig, config); // CLI config takes precedence - return resolvedConfig; - }; + let loadConfigFn = options.loadConfig || loadConfig; + let fileConfig = await loadConfigFn(); + let resolvedConfig = { ...fileConfig, ...config }; + + return new VizzlySDK(resolvedConfig, { + createUploader: (uploaderOptions = {}, activeConfig = resolvedConfig) => { + let { upload: uploadConfig = activeConfig.upload, ...serviceOptions } = + uploaderOptions; + let createUploaderService = options.createUploader || createUploader; + return createUploaderService( + { + apiKey: activeConfig.apiKey, + apiUrl: activeConfig.apiUrl, + upload: uploadConfig, + }, + { ...options, ...serviceOptions } + ); + }, + createTDDService: (tddOptions = {}, activeConfig = resolvedConfig) => { + let createTDDServiceInstance = + options.createTDDService || createTDDService; + return createTDDServiceInstance(activeConfig, { + ...options, + ...tddOptions, + }); + }, + createScreenshotServer: createLocalScreenshotServer, + fetch: options.fetch, + loadConfig: loadConfigFn, + }); +} - /** - * Create uploader service - */ - const createUploaderService = (uploaderOptions = {}) => { - return createUploader( - { apiKey: resolvedConfig.apiKey, apiUrl: resolvedConfig.apiUrl }, - { ...options, ...uploaderOptions } - ); - }; +function createLocalBuildManager() { + let screenshots = new Map(); - /** - * Create TDD service - */ - const createTDDServiceInstance = (tddOptions = {}) => { - return createTDDService(resolvedConfig, { - ...options, - ...tddOptions, - }); - }; + return { + async addScreenshot(buildId, screenshot) { + if (!screenshots.has(buildId)) { + screenshots.set(buildId, []); + } + screenshots.get(buildId).push(screenshot); + }, - /** - * Upload screenshots (convenience method) - */ - const upload = async uploadOptions => { - const uploader = createUploaderService(); - return uploader.upload(uploadOptions); + getScreenshots(buildId) { + return screenshots.get(buildId) || []; + }, }; +} - /** - * Start TDD mode (convenience method) - */ - const startTDD = async (tddOptions = {}) => { - const tddService = createTDDServiceInstance(); - return tddService.start(tddOptions); +function createLocalScreenshotServer(config, buildManager, options = {}) { + let server = null; + let deps = { + createHttpServer: options.createHttpServer || createServer, + output: options.output || output, + createError: + options.createError || + ((message, code) => new VizzlyError(message, code)), }; return { - // Core methods - init, - upload, - startTDD, - - // Service factories - createUploader: createUploaderService, - createTDDService: createTDDServiceInstance, + async start() { + server = await startScreenshotServer({ + config, + requestHandler: (req, res) => + handleScreenshotRequest({ + req, + res, + deps: { + buildManager, + createError: deps.createError, + output: deps.output, + }, + }), + deps, + }); + }, - // Utilities - loadConfig: () => loadConfig(), + async stop() { + await stopScreenshotServer({ server, deps: { output: deps.output } }); + server = null; + }, - // Config access - getConfig: () => ({ ...resolvedConfig }), - updateConfig: newConfig => Object.assign(resolvedConfig, newConfig), + isRunning() { + return Boolean(server?.listening); + }, }; } @@ -150,7 +177,7 @@ export class VizzlySDK extends EventEmitter { constructor(config, services) { super(); this.config = config; - this.services = services; + this.services = services || {}; this.server = null; this.currentBuildId = null; } @@ -176,6 +203,71 @@ export class VizzlySDK extends EventEmitter { return { ...this.config }; } + /** + * Merge new config values into the active SDK config. + * @param {Object} newConfig - Config updates + * @returns {Object} Updated config + */ + updateConfig(newConfig) { + Object.assign(this.config, newConfig); + return this.getConfig(); + } + + /** + * Reload file config and re-apply current in-memory overrides. + * @returns {Promise} Updated config + */ + async init() { + let loadConfigFn = this.services.loadConfig || loadConfig; + let fileConfig = await loadConfigFn(); + let currentConfig = { ...this.config }; + Object.assign(this.config, fileConfig, currentConfig); + return this.getConfig(); + } + + createUploader(options = {}) { + let { upload: uploadConfig = this.config.upload, ...serviceOptions } = + options; + let createUploaderService = + this.services.createUploader || + ((uploaderOptions, activeConfig = this.config) => { + let { + upload: resolvedUploadConfig = activeConfig.upload, + ...resolvedServiceOptions + } = uploaderOptions; + return createUploader( + { + apiKey: activeConfig.apiKey, + apiUrl: activeConfig.apiUrl, + upload: resolvedUploadConfig, + }, + resolvedServiceOptions + ); + }); + + return createUploaderService( + { + upload: uploadConfig, + ...serviceOptions, + }, + this.config + ); + } + + createTDDService(options = {}) { + let createTDDServiceInstance = + this.services.createTDDService || + ((tddOptions, activeConfig = this.config) => + createTDDService(activeConfig, tddOptions)); + + return createTDDServiceInstance(options, this.config); + } + + async startTDD(options = {}) { + let tddService = this.createTDDService(options); + return tddService.start(options); + } + /** * Start the Vizzly server * @returns {Promise<{port: number, url: string}>} Server information @@ -189,29 +281,15 @@ export class VizzlySDK extends EventEmitter { }; } - // Create a simple build manager for screenshot collection - const buildManager = { - screenshots: new Map(), - currentBuildId: null, - - async addScreenshot(buildId, screenshot) { - if (!this.screenshots.has(buildId)) { - this.screenshots.set(buildId, []); - } - this.screenshots.get(buildId).push(screenshot); - }, - - getScreenshots(buildId) { - return this.screenshots.get(buildId) || []; - }, - }; - - this.server = new ScreenshotServer(this.config, buildManager); + let createScreenshotServer = + this.services.createScreenshotServer || createLocalScreenshotServer; + let buildManager = createLocalBuildManager(); + this.server = createScreenshotServer(this.config, buildManager); await this.server.start(); - const port = this.config.server?.port || 3000; - const serverInfo = { + let port = this.config.server?.port || 3000; + let serverInfo = { port, url: `http://localhost:${port}`, }; @@ -231,7 +309,7 @@ export class VizzlySDK extends EventEmitter { * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors */ async screenshot(name, imageBuffer, options = {}) { - if (!this.server?.isRunning()) { + if (this.server?.isRunning?.() !== true) { throw new VizzlyError( 'Server not running. Call start() first.', 'SERVER_NOT_RUNNING' @@ -239,28 +317,29 @@ export class VizzlySDK extends EventEmitter { } // Resolve Buffer or file path using shared utility - const buffer = resolveImageBuffer(imageBuffer, 'screenshot'); + let buffer = resolveImageBuffer(imageBuffer, 'screenshot'); // Generate or use provided build ID - const buildId = options.buildId || this.currentBuildId || 'default'; + let buildId = options.buildId || this.currentBuildId || 'default'; this.currentBuildId = buildId; // Convert Buffer to base64 for JSON transport - const imageBase64 = buffer.toString('base64'); + let imageBase64 = buffer.toString('base64'); - const screenshotData = { + let screenshotData = { buildId, name, image: imageBase64, type: 'base64', - properties: options.properties || {}, + properties: createScreenshotProperties(options), }; // POST to the local screenshot server - const serverUrl = `http://localhost:${this.config.server?.port || 3000}`; + let serverUrl = `http://localhost:${this.config.server?.port || 3000}`; + let fetchFn = this.services.fetch || fetch; try { - const response = await fetch(`${serverUrl}/screenshot`, { + let response = await fetchFn(`${serverUrl}/screenshot`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -269,7 +348,7 @@ export class VizzlySDK extends EventEmitter { }); if (!response.ok) { - const errorData = await response + let errorData = await response .json() .catch(() => ({ error: 'Unknown error' })); throw new VizzlyError( @@ -300,20 +379,18 @@ export class VizzlySDK extends EventEmitter { async upload(options = {}) { if (!this.services?.uploader) { this.services = this.services || {}; - this.services.uploader = createUploader({ - apiKey: this.config.apiKey, - apiUrl: this.config.apiUrl, + this.services.uploader = this.createUploader({ upload: this.config.upload, }); } // Get the screenshots directory from config or default - const screenshotsDir = + let screenshotsDir = options.screenshotsDir || this.config?.upload?.screenshotsDir || './screenshots'; - const uploadOptions = { + let uploadOptions = { screenshotsDir, buildName: options.buildName || this.config.buildName, branch: options.branch || this.config.branch, @@ -321,7 +398,7 @@ export class VizzlySDK extends EventEmitter { message: options.message || this.config.message, environment: options.environment || this.config.environment || 'production', - threshold: options.threshold || this.config.threshold, + threshold: options.threshold ?? this.config.threshold, onProgress: progress => { this.emit('upload:progress', progress); if (options.onProgress) { @@ -331,7 +408,7 @@ export class VizzlySDK extends EventEmitter { }; try { - const result = await this.services.uploader.upload(uploadOptions); + let result = await this.services.uploader.upload(uploadOptions); this.emit('upload:completed', result); return result; } catch (error) { @@ -351,14 +428,14 @@ export class VizzlySDK extends EventEmitter { async compare(name, imageBuffer) { if (!this.services?.tddService) { this.services = this.services || {}; - this.services.tddService = createTDDService(this.config); + this.services.tddService = this.createTDDService(); } // Resolve Buffer or file path using shared utility - const buffer = resolveImageBuffer(imageBuffer, 'compare'); + let buffer = resolveImageBuffer(imageBuffer, 'compare'); try { - const result = await this.services.tddService.compareScreenshot( + let result = await this.services.tddService.compareScreenshot( name, buffer ); diff --git a/src/types/client.d.ts b/src/types/client.d.ts index e3261ebe..fb651824 100644 --- a/src/types/client.d.ts +++ b/src/types/client.d.ts @@ -4,6 +4,40 @@ * @module @vizzly-testing/cli/client */ +export type ClientLogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export const LOG_LEVELS: Readonly>; + +/** + * Check whether client SDK output should log at the requested level. + */ +export function shouldLogClient( + level: string, + configuredLevel?: string +): boolean; + +/** + * Auto-discover a local TDD server by searching for `.vizzly/server.json`. + */ +export function autoDiscoverTddServer( + startDir?: string, + deps?: { + exists?: (path: string) => boolean; + readFile?: (path: string, encoding: BufferEncoding) => string | Buffer; + } +): string | null; + +/** + * Result returned by a successful screenshot capture. + */ +export interface ScreenshotResult { + success: boolean; + status?: 'passed' | 'failed' | 'new'; + name?: string; + diffPercentage?: number; + [key: string]: unknown; +} + /** * Take a screenshot for visual regression testing * @@ -38,8 +72,9 @@ export function vizzlyScreenshot( threshold?: number; minClusterSize?: number; fullPage?: boolean; + [key: string]: unknown; } -): Promise; +): Promise; /** * Flush result summary returned by vizzlyFlush diff --git a/src/types/index.d.ts b/src/types/index.d.ts index ed3a3670..8c306d4b 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -77,6 +77,7 @@ export interface ScreenshotOptions { minClusterSize?: number; fullPage?: boolean; buildId?: string; + [key: string]: unknown; } export interface ScreenshotResult { @@ -84,6 +85,7 @@ export interface ScreenshotResult { status?: 'passed' | 'failed' | 'new'; name?: string; diffPercentage?: number; + [key: string]: unknown; } // ============================================================================ @@ -262,6 +264,12 @@ export class ValidationError extends VizzlyError { export interface VizzlySDKInstance extends EventEmitter { config: VizzlyConfig; + /** Reload file config and re-apply current in-memory overrides */ + init(): Promise; + + /** Merge new config values into the active SDK config */ + updateConfig(newConfig: Partial): VizzlyConfig; + /** Start the Vizzly server */ start(): Promise<{ port: number; url: string }>; @@ -286,6 +294,28 @@ export interface VizzlySDKInstance extends EventEmitter { name: string, imageBuffer: Buffer | string ): Promise; + + /** Create an uploader using the SDK config */ + createUploader(options?: { + upload?: UploadConfig; + signal?: AbortSignal; + batchSize?: number; + timeout?: number; + }): Uploader; + + /** Create a local TDD service using the SDK config */ + createTDDService(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): TddService; + + /** Start a local TDD service */ + startTDD(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): Promise; } export class VizzlySDK extends EventEmitter implements VizzlySDKInstance { @@ -293,6 +323,8 @@ export class VizzlySDK extends EventEmitter implements VizzlySDKInstance { constructor(config: VizzlyConfig, services: unknown); + init(): Promise; + updateConfig(newConfig: Partial): VizzlyConfig; start(): Promise<{ port: number; url: string }>; stop(): Promise; getConfig(): VizzlyConfig; @@ -306,6 +338,22 @@ export class VizzlySDK extends EventEmitter implements VizzlySDKInstance { name: string, imageBuffer: Buffer | string ): Promise; + createUploader(options?: { + upload?: UploadConfig; + signal?: AbortSignal; + batchSize?: number; + timeout?: number; + }): Uploader; + createTDDService(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): TddService; + startTDD(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): Promise; } // ============================================================================ @@ -344,15 +392,8 @@ export interface TddService { } export interface Services { - apiService: unknown; - authService: unknown; - configService: unknown; - projectService: unknown; - uploader: Uploader; - buildManager: unknown; - serverManager: unknown; - tddService: TddService; - testRunner: unknown; + serverManager: ServerManager; + testRunner: TestRunnerService; } // ============================================================================ @@ -392,11 +433,47 @@ export interface PluginServerManager { stop(): Promise; } +/** + * Internal server manager returned by createServices(). + */ +export interface ServerManager extends PluginServerManager { + /** Get current TDD results from the local server handler */ + getTddResults(): Promise; + /** Current HTTP server facade, if running */ + readonly server: unknown; +} + +/** + * Internal TestRunner returned by createServices(). + */ +export interface TestRunnerService extends PluginTestRunner { + /** Initialize daemon mode without running tests */ + initialize(options: Record): Promise; + /** Run tests through the configured test command */ + run(options: Record): Promise; + /** Cancel the active test process and stop the server */ + cancel(): Promise; +} + +export interface PluginGitInfo { + branch: string; + commit: string | null; + message: string | null; + prNumber: number | null; + buildName: string; +} + +export interface PluginGit { + /** Detect branch, commit, commit message, PR number, and build name */ + detect(options?: { buildPrefix?: string }): Promise; +} + /** * Stable services interface for plugins. * This is the public API contract - internal services are NOT exposed. */ export interface PluginServices { + git: PluginGit; testRunner: PluginTestRunner; serverManager: PluginServerManager; } @@ -432,8 +509,6 @@ export interface PluginContext { services: PluginServices; /** Output utilities for logging */ output: OutputUtils; - /** @deprecated Use output instead. Alias for backwards compatibility. */ - logger: OutputUtils; } /** Create stable plugin services from internal services */ @@ -444,12 +519,31 @@ export function createPluginServices(services: Services): PluginServices; // ============================================================================ export interface OutputUtils { - info(message: string): void; - warn(message: string): void; - error(message: string): void; - success(message: string): void; - debug(category: string, ...args: unknown[]): void; - configure(options: { verbose?: boolean }): void; + info(message: string, data?: Record): void; + warn(message: string, data?: Record): void; + error( + message: string, + error?: Error | null, + data?: Record + ): void; + success(message: string, data?: Record): void; + debug( + component: string, + message: string, + data?: Record + ): void; + configure(options: OutputConfigureOptions): void; +} + +export interface OutputConfigureOptions { + json?: boolean | string; + jsonFields?: string[] | null; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + verbose?: boolean; + color?: boolean; + silent?: boolean; + logFile?: string | null; + resetTimer?: boolean; } // ============================================================================ @@ -471,8 +565,9 @@ export function vizzlyScreenshot( threshold?: number; minClusterSize?: number; fullPage?: boolean; + [key: string]: unknown; } -): Promise; +): Promise; /** Configure the Vizzly client */ export function configure(config?: { @@ -510,10 +605,7 @@ export function createTDDService( ): TddService; /** Create all services with dependencies */ -export function createServices( - config: VizzlyConfig, - command?: string -): Services; +export function createServices(config: VizzlyConfig): Services; /** Load configuration from file and environment */ export function loadConfig(options?: { cwd?: string }): Promise; diff --git a/src/types/sdk.d.ts b/src/types/sdk.d.ts index 4843c449..0c3335d6 100644 --- a/src/types/sdk.d.ts +++ b/src/types/sdk.d.ts @@ -41,6 +41,18 @@ export class VizzlySDK extends EventEmitter { constructor(config: import('./index').VizzlyConfig, services: unknown); + /** + * Reload file config and re-apply current in-memory overrides. + */ + init(): Promise; + + /** + * Merge new config values into the active SDK config. + */ + updateConfig( + newConfig: Partial + ): import('./index').VizzlyConfig; + /** * Start the Vizzly server * @returns Server information including port and URL @@ -90,6 +102,34 @@ export class VizzlySDK extends EventEmitter { name: string, imageBuffer: Buffer | string ): Promise; + + /** + * Create an uploader using the SDK config. + */ + createUploader(options?: { + upload?: import('./index').UploadConfig; + signal?: AbortSignal; + batchSize?: number; + timeout?: number; + }): import('./index').Uploader; + + /** + * Create a local TDD service using the SDK config. + */ + createTDDService(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): import('./index').TddService; + + /** + * Start a local TDD service. + */ + startTDD(options?: { + workingDir?: string; + setBaseline?: boolean; + authService?: unknown; + }): Promise; } /** diff --git a/src/utils/api-url.js b/src/utils/api-url.js new file mode 100644 index 00000000..0ba67006 --- /dev/null +++ b/src/utils/api-url.js @@ -0,0 +1,15 @@ +export function getAppBaseUrl(apiUrl) { + try { + let url = new URL(apiUrl); + let apiPathIndex = url.pathname.indexOf('/api'); + if (apiPathIndex !== -1) { + url.pathname = url.pathname.slice(0, apiPathIndex) || '/'; + url.search = ''; + url.hash = ''; + } + + return url.toString().replace(/\/$/, ''); + } catch { + return apiUrl.replace(/\/api(?:\/.*)?$/, ''); + } +} diff --git a/src/utils/async-utils.js b/src/utils/async-utils.js new file mode 100644 index 00000000..2568e804 --- /dev/null +++ b/src/utils/async-utils.js @@ -0,0 +1,29 @@ +/** + * Race an operation against a timeout and always clear the timer when either + * side settles. + * + * @param {Promise} operation - Operation promise to wait for. + * @param {number} ms - Timeout in milliseconds. + * @param {string} message - Error message when the timeout wins. + * @param {Object} [timers] - Timer implementation for deterministic tests. + * @returns {Promise} The operation result. + */ +export async function withTimeout( + operation, + ms, + message, + timers = { setTimeout, clearTimeout } +) { + let timeoutId; + let timeoutPromise = new Promise((_, reject) => { + timeoutId = timers.setTimeout(() => reject(new Error(message)), ms); + }); + + try { + return await Promise.race([operation, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + timers.clearTimeout(timeoutId); + } + } +} diff --git a/src/utils/fetch-utils.js b/src/utils/fetch-utils.js index d09592de..46d73640 100644 --- a/src/utils/fetch-utils.js +++ b/src/utils/fetch-utils.js @@ -1,8 +1,14 @@ -function fetchWithTimeout(url, opts = {}, ms = 300000) { - const ctrl = new AbortController(); - const id = setTimeout(() => ctrl.abort(), ms); - return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => - clearTimeout(id) +let defaultTimers = { setTimeout, clearTimeout }; + +function fetchWithTimeout(url, opts = {}, ms = 300000, deps = {}) { + let fetchFn = deps.fetch || fetch; + let AbortControllerClass = deps.AbortController || AbortController; + let timers = deps.timers || defaultTimers; + let ctrl = new AbortControllerClass(); + let timeoutId = timers.setTimeout(() => ctrl.abort(), ms); + + return fetchFn(url, { ...opts, signal: ctrl.signal }).finally(() => + timers.clearTimeout(timeoutId) ); } diff --git a/src/utils/patterns.js b/src/utils/patterns.js new file mode 100644 index 00000000..43df6e75 --- /dev/null +++ b/src/utils/patterns.js @@ -0,0 +1,13 @@ +export function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function createWildcardMatcher(pattern, options = {}) { + let { anchored = false } = options; + let regexPattern = String(pattern).split('*').map(escapeRegExp).join('.*'); + if (anchored) { + regexPattern = `^${regexPattern}$`; + } + let regex = new RegExp(regexPattern, 'i'); + return value => regex.test(String(value)); +} diff --git a/src/utils/screenshot-options.js b/src/utils/screenshot-options.js new file mode 100644 index 00000000..b083b07e --- /dev/null +++ b/src/utils/screenshot-options.js @@ -0,0 +1,33 @@ +/** + * Normalize screenshot SDK options into the properties payload consumed by the + * local TDD server and cloud-compatible comparison path. + */ +export function createScreenshotProperties(options = {}) { + let { + buildId: _buildId, + properties = {}, + threshold, + minClusterSize, + fullPage, + ...topLevelProperties + } = options; + + let normalizedProperties = { + ...topLevelProperties, + ...properties, + }; + + if (threshold !== undefined) { + normalizedProperties.threshold = threshold; + } + + if (minClusterSize !== undefined) { + normalizedProperties.minClusterSize = minClusterSize; + } + + if (fullPage !== undefined) { + normalizedProperties.fullPage = fullPage; + } + + return normalizedProperties; +} diff --git a/test-d/client.test-d.ts b/test-d/client.test-d.ts index 87ee48e8..58e1d844 100644 --- a/test-d/client.test-d.ts +++ b/test-d/client.test-d.ts @@ -3,26 +3,34 @@ */ import { expectType, expectError } from 'tsd'; import { + autoDiscoverTddServer, vizzlyScreenshot, vizzlyFlush, isVizzlyReady, configure, setEnabled, getVizzlyInfo, + LOG_LEVELS, + shouldLogClient, } from '../src/types/client'; +import type { ScreenshotResult } from '../src/types/client'; // ============================================================================ // vizzlyScreenshot // ============================================================================ // Should accept Buffer as second argument -expectType>(vizzlyScreenshot('test', Buffer.from('test'))); +expectType>( + vizzlyScreenshot('test', Buffer.from('test')) +); // Should accept string (file path) as second argument -expectType>(vizzlyScreenshot('test', './path/to/image.png')); +expectType>( + vizzlyScreenshot('test', './path/to/image.png') +); // Should accept options object -expectType>( +expectType>( vizzlyScreenshot('test', Buffer.from('test'), { properties: { browser: 'chrome' }, threshold: 5, @@ -30,8 +38,16 @@ expectType>( }) ); +// Should accept top-level screenshot properties +expectType>( + vizzlyScreenshot('test', Buffer.from('test'), { + browser: 'chrome', + viewport: '1920x1080', + }) +); + // Should accept partial options -expectType>( +expectType>( vizzlyScreenshot('test', Buffer.from('test'), { threshold: 10 }) ); @@ -100,3 +116,23 @@ expectType(info.ready); expectType(info.buildId); expectType(info.tddMode); expectType(info.disabled); + +// ============================================================================ +// Public helper exports +// ============================================================================ + +expectType(LOG_LEVELS.debug); +expectType(LOG_LEVELS.info); +expectType(LOG_LEVELS.warn); +expectType(LOG_LEVELS.error); + +expectType(shouldLogClient('error')); +expectType(shouldLogClient('debug', 'warn')); + +expectType(autoDiscoverTddServer()); +expectType( + autoDiscoverTddServer('/workspace/project', { + exists: path => path.endsWith('server.json'), + readFile: () => JSON.stringify({ port: 47392 }), + }) +); diff --git a/test-d/main.test-d.ts b/test-d/main.test-d.ts index 52bf3381..368d097c 100644 --- a/test-d/main.test-d.ts +++ b/test-d/main.test-d.ts @@ -16,6 +16,7 @@ import { createUploader, createTDDService, createServices, + createPluginServices, // Utilities loadConfig, @@ -39,7 +40,9 @@ import { VizzlyConfig, UploadResult, ComparisonResult, + ScreenshotResult, Services, + PluginServices, Uploader, TddService, } from '../src/types/index'; @@ -55,7 +58,9 @@ async function testCreateVizzlyType() { } // Client -expectType>(vizzlyScreenshot('test', Buffer.from('test'))); +expectType>( + vizzlyScreenshot('test', Buffer.from('test')) +); configure({}); setEnabled(true); @@ -160,6 +165,12 @@ async function testUploadResult() { async function testComparisonResult() { let sdk = await createVizzly(); + expectType(sdk.updateConfig({ wait: true })); + expectType>(sdk.init()); + expectType(sdk.createUploader()); + expectType(sdk.createTDDService()); + expectType>(sdk.startTDD()); + let result = await sdk.compare('test', Buffer.from('test')); expectType(result.id); @@ -177,5 +188,16 @@ async function testComparisonResult() { // ============================================================================ let services = createServices({}); -expectType(services.uploader); -expectType(services.tddService); +expectType(services); +expectType>(services.serverManager.stop()); +expectType>(services.testRunner.cancel()); + +let pluginServices = createPluginServices(services); +expectType(pluginServices); +expectType>(pluginServices.git.detect()); diff --git a/test-d/sdk.test-d.ts b/test-d/sdk.test-d.ts index 64e3465b..d5bb34fe 100644 --- a/test-d/sdk.test-d.ts +++ b/test-d/sdk.test-d.ts @@ -52,6 +52,14 @@ async function testVizzlySDK() { // getConfig() should return VizzlyConfig expectType(sdk.getConfig()); + // updateConfig() should return the updated config + expectType( + sdk.updateConfig({ build: { environment: 'staging' } }) + ); + + // init() should reload configuration + expectType>(sdk.init()); + // screenshot() with Buffer expectType>(sdk.screenshot('test', Buffer.from('test'))); @@ -79,6 +87,17 @@ async function testVizzlySDK() { // compare() should return ComparisonResult expectType>(sdk.compare('test', Buffer.from('test'))); expectType>(sdk.compare('test', '/path/to/image.png')); + + // advanced service factories should match their public service contracts + expectType(sdk.createUploader()); + expectType( + sdk.createUploader({ + batchSize: 10, + upload: { screenshotsDir: './screenshots' }, + }) + ); + expectType(sdk.createTDDService()); + expectType>(sdk.startTDD({ workingDir: '/tmp/vizzly' })); } // VizzlySDK should be an EventEmitter (has on/off/emit) diff --git a/tests/api/endpoints.test.js b/tests/api/endpoints.test.js index 42703649..fa4a688e 100644 --- a/tests/api/endpoints.test.js +++ b/tests/api/endpoints.test.js @@ -6,6 +6,7 @@ */ import assert from 'node:assert'; +import { createHash } from 'node:crypto'; import { describe, it } from 'node:test'; import { checkShas, @@ -362,8 +363,7 @@ describe('api/endpoints', () => { it('skips upload when SHA exists', async () => { let buffer = Buffer.from('fake png data'); // Compute the actual SHA that will be generated - let crypto = await import('node:crypto'); - let sha = crypto.createHash('sha256').update(buffer).digest('hex'); + let sha = createHash('sha256').update(buffer).digest('hex'); let client = createMockClient(endpoint => { if (endpoint === '/api/sdk/check-shas') { diff --git a/tests/sdk/client.test.js b/tests/sdk/client.test.js index d7a8b1ba..7bb391f8 100644 --- a/tests/sdk/client.test.js +++ b/tests/sdk/client.test.js @@ -237,31 +237,30 @@ describe('client/index', () => { }); describe('vizzlyScreenshot (unit tests)', () => { - it('returns early when disabled', async () => { + it('returns null when disabled', async () => { configure({ enabled: false }); let result = await vizzlyScreenshot('test', Buffer.from('image')); - assert.strictEqual(result, undefined); + assert.strictEqual(result, null); }); - it('returns undefined or null when no server configured', async () => { + it('returns null when no server configured', async () => { configure({ enabled: true, serverUrl: null }); let result = await vizzlyScreenshot('test', Buffer.from('image')); - // Returns undefined if no server discovered, null if server failed - assert.ok(result === undefined || result === null); + assert.strictEqual(result, null); }); }); describe('vizzlyFlush (unit tests)', () => { - it('returns undefined when no client', async () => { + it('returns null when no client', async () => { configure({ enabled: false }); let result = await vizzlyFlush(); - assert.strictEqual(result, undefined); + assert.strictEqual(result, null); }); }); @@ -388,23 +387,44 @@ describe('client/index httpPost integration tests', () => { }); }); - it('excludes SDK options from properties', async () => { + it('sends comparison options in properties for the TDD server', async () => { await vizzlyScreenshot('test', Buffer.from('data'), { fullPage: true, threshold: 0.1, + minClusterSize: 4, properties: { url: 'http://localhost:3000' }, browser: 'firefox', }); assert.strictEqual(requests.length, 1); - let { properties, fullPage } = requests[0].body; - assert.strictEqual(fullPage, true); + let { properties, fullPage, threshold, minClusterSize } = requests[0].body; + assert.strictEqual(fullPage, undefined); + assert.strictEqual(threshold, undefined); + assert.strictEqual(minClusterSize, undefined); assert.deepStrictEqual(properties, { browser: 'firefox', url: 'http://localhost:3000', + threshold: 0.1, + minClusterSize: 4, + fullPage: true, + }); + }); + + it('lets explicit comparison options override nested properties', async () => { + await vizzlyScreenshot('test', Buffer.from('data'), { + threshold: 0, + minClusterSize: 2, + properties: { + threshold: 5, + minClusterSize: 10, + }, + }); + + assert.strictEqual(requests.length, 1); + assert.deepStrictEqual(requests[0].body.properties, { + threshold: 0, + minClusterSize: 2, }); - assert.strictEqual(properties.fullPage, undefined); - assert.strictEqual(properties.threshold, undefined); }); it('sends Connection: close header to disable keep-alive', async () => { @@ -433,7 +453,8 @@ describe('client/index httpPost integration tests', () => { // Need to reconfigure to actually hit the different endpoint // Actually, let's use a different approach - modify what endpoint we hit - server.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); requests = []; server = createServer((req, res) => { @@ -477,14 +498,12 @@ describe('client/index httpPost integration tests', () => { }); it('handles server error and disables SDK', async () => { - server.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); requests = []; server = createServer((req, res) => { - let _body = ''; - req.on('data', chunk => { - _body += chunk; - }); + req.on('data', () => {}); req.on('end', () => { requests.push({ method: req.method, url: req.url }); res.setHeader('Content-Type', 'application/json'); @@ -508,14 +527,12 @@ describe('client/index httpPost integration tests', () => { }); it('handles invalid JSON response gracefully', async () => { - server.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); requests = []; server = createServer((req, res) => { - let _body = ''; - req.on('data', chunk => { - _body += chunk; - }); + req.on('data', () => {}); req.on('end', () => { requests.push({ method: req.method, url: req.url }); res.setHeader('Content-Type', 'text/plain'); diff --git a/tests/sdk/index.test.js b/tests/sdk/index.test.js new file mode 100644 index 00000000..3bd81cdf --- /dev/null +++ b/tests/sdk/index.test.js @@ -0,0 +1,266 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { createVizzly, VizzlySDK } from '../../src/sdk/index.js'; + +describe('sdk/index', () => { + describe('createVizzly', () => { + it('returns an initialized VizzlySDK and lets explicit config win', async () => { + let sdk = await createVizzly( + { + apiKey: 'explicit-token', + server: { port: 6000 }, + }, + { + loadConfig: async () => ({ + apiKey: 'file-token', + apiUrl: 'https://from-config.example', + server: { port: 3000 }, + }), + } + ); + + assert.ok(sdk instanceof VizzlySDK); + assert.deepStrictEqual(sdk.getConfig(), { + apiKey: 'explicit-token', + apiUrl: 'https://from-config.example', + server: { port: 6000 }, + }); + }); + + it('uses the current SDK config when creating services after config updates', async () => { + let uploaderConfigs = []; + let tddConfigs = []; + let sdk = await createVizzly( + { + apiKey: 'initial-token', + apiUrl: 'https://initial.example', + upload: { screenshotsDir: './initial' }, + }, + { + loadConfig: async () => ({}), + createUploader: (config, options) => { + uploaderConfigs.push({ config, options }); + return { upload: async () => ({ success: true }) }; + }, + createTDDService: (config, options) => { + tddConfigs.push({ config, options }); + return { start: async () => ({ started: true }) }; + }, + } + ); + + sdk.updateConfig({ + apiKey: 'updated-token', + apiUrl: 'https://updated.example', + upload: { screenshotsDir: './updated' }, + }); + + sdk.createUploader({ batchSize: 5 }); + sdk.createTDDService({ workingDir: '/tmp/vizzly' }); + + assert.deepStrictEqual(uploaderConfigs[0].config, { + apiKey: 'updated-token', + apiUrl: 'https://updated.example', + upload: { screenshotsDir: './updated' }, + }); + assert.equal(uploaderConfigs[0].options.batchSize, 5); + assert.deepStrictEqual(tddConfigs[0].config, { + apiKey: 'updated-token', + apiUrl: 'https://updated.example', + upload: { screenshotsDir: './updated' }, + }); + assert.equal(tddConfigs[0].options.workingDir, '/tmp/vizzly'); + }); + }); + + describe('VizzlySDK server workflow', () => { + it('updates config and creates SDK-owned services', async () => { + let loadCount = 0; + let uploaderOptions = []; + let tddOptions = []; + let startedTddOptions = null; + let sdk = new VizzlySDK( + { + apiKey: 'runtime-token', + upload: { screenshotsDir: './original' }, + }, + { + loadConfig: async () => { + loadCount += 1; + return { + apiUrl: 'https://from-file.example', + upload: { screenshotsDir: './from-file' }, + }; + }, + createUploader: options => { + uploaderOptions.push(options); + return { upload: async () => ({ success: true }) }; + }, + createTDDService: options => { + tddOptions.push(options); + return { + start: async startOptions => { + startedTddOptions = startOptions; + return { started: true }; + }, + }; + }, + } + ); + + assert.deepStrictEqual( + sdk.updateConfig({ build: { environment: 'staging' } }), + { + apiKey: 'runtime-token', + upload: { screenshotsDir: './original' }, + build: { environment: 'staging' }, + } + ); + + assert.deepStrictEqual(await sdk.init(), { + apiKey: 'runtime-token', + apiUrl: 'https://from-file.example', + upload: { screenshotsDir: './original' }, + build: { environment: 'staging' }, + }); + assert.strictEqual(loadCount, 1); + + let uploader = sdk.createUploader({ + upload: { screenshotsDir: './override' }, + batchSize: 5, + }); + let tddService = sdk.createTDDService({ workingDir: '/tmp/vizzly' }); + let tddStartResult = await sdk.startTDD({ setBaseline: true }); + + assert.ok(uploader); + assert.ok(tddService); + assert.deepStrictEqual(uploaderOptions, [ + { + upload: { screenshotsDir: './override' }, + batchSize: 5, + }, + ]); + assert.deepStrictEqual(tddOptions, [ + { workingDir: '/tmp/vizzly' }, + { setBaseline: true }, + ]); + assert.deepStrictEqual(startedTddOptions, { setBaseline: true }); + assert.deepStrictEqual(tddStartResult, { started: true }); + }); + + it('preserves exact-match threshold when uploading screenshots', async () => { + let capturedUploadOptions = null; + let sdk = new VizzlySDK( + { + threshold: 2.0, + upload: { screenshotsDir: './screenshots' }, + }, + { + createUploader: () => ({ + upload: async uploadOptions => { + capturedUploadOptions = uploadOptions; + return { buildId: 'build-123' }; + }, + }), + } + ); + + let result = await sdk.upload({ threshold: 0 }); + + assert.deepStrictEqual(result, { buildId: 'build-123' }); + assert.strictEqual(capturedUploadOptions.threshold, 0); + }); + + it('starts once, captures screenshots through the local server, and stops', async () => { + let running = false; + let stopped = false; + let fetchCalls = []; + let startedEvents = []; + let capturedEvents = []; + let stoppedEvents = 0; + let sdk = new VizzlySDK( + { server: { port: 8123 } }, + { + createScreenshotServer: () => ({ + async start() { + running = true; + }, + async stop() { + stopped = true; + running = false; + }, + isRunning() { + return running; + }, + }), + fetch: async (url, options) => { + fetchCalls.push({ url, options }); + return { ok: true }; + }, + } + ); + + sdk.on('server:started', info => startedEvents.push(info)); + sdk.on('screenshot:captured', event => capturedEvents.push(event)); + sdk.on('server:stopped', () => { + stoppedEvents += 1; + }); + + let serverInfo = await sdk.start(); + await sdk.screenshot('homepage', Buffer.from('image-data'), { + buildId: 'build-1', + properties: { browser: 'firefox' }, + threshold: 0, + minClusterSize: 3, + fullPage: true, + }); + await sdk.stop(); + + assert.deepStrictEqual(serverInfo, { + port: 8123, + url: 'http://localhost:8123', + }); + assert.deepStrictEqual(startedEvents, [serverInfo]); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls[0].url, 'http://localhost:8123/screenshot'); + assert.deepStrictEqual(JSON.parse(fetchCalls[0].options.body), { + buildId: 'build-1', + name: 'homepage', + image: Buffer.from('image-data').toString('base64'), + type: 'base64', + properties: { + browser: 'firefox', + threshold: 0, + minClusterSize: 3, + fullPage: true, + }, + }); + assert.deepStrictEqual(capturedEvents, [ + { + name: 'homepage', + buildId: 'build-1', + options: { + buildId: 'build-1', + properties: { browser: 'firefox' }, + threshold: 0, + minClusterSize: 3, + fullPage: true, + }, + }, + ]); + assert.strictEqual(stopped, true); + assert.strictEqual(stoppedEvents, 1); + }); + + it('fails clearly when screenshot is called before start', async () => { + let sdk = new VizzlySDK({ server: { port: 8123 } }, {}); + + await assert.rejects( + () => sdk.screenshot('homepage', Buffer.from('image-data')), + error => + error.code === 'SERVER_NOT_RUNNING' && + error.message.includes('Server not running') + ); + }); + }); +}); diff --git a/tests/utils/api-url.test.js b/tests/utils/api-url.test.js new file mode 100644 index 00000000..e877e228 --- /dev/null +++ b/tests/utils/api-url.test.js @@ -0,0 +1,34 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { getAppBaseUrl } from '../../src/utils/api-url.js'; + +describe('getAppBaseUrl', () => { + it('removes API path segments without touching api hostnames', () => { + assert.strictEqual( + getAppBaseUrl('https://api.test/api/v1'), + 'https://api.test' + ); + assert.strictEqual( + getAppBaseUrl('https://api.vizzly.dev'), + 'https://api.vizzly.dev' + ); + }); + + it('handles API URLs with the API path at the end', () => { + assert.strictEqual( + getAppBaseUrl('https://host.test/api'), + 'https://host.test' + ); + }); + + it('removes search and hash fragments from app links', () => { + assert.strictEqual( + getAppBaseUrl('https://host.test/api/v1?token=abc#section'), + 'https://host.test' + ); + }); + + it('handles plain API strings with the legacy path fallback', () => { + assert.strictEqual(getAppBaseUrl('host.test/api'), 'host.test'); + }); +}); diff --git a/tests/utils/async-utils.test.js b/tests/utils/async-utils.test.js new file mode 100644 index 00000000..608cad32 --- /dev/null +++ b/tests/utils/async-utils.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { withTimeout } from '../../src/utils/async-utils.js'; + +function createManualTimers() { + let timers = new Map(); + let cleared = []; + let nextId = 1; + + return { + setTimeout(fn, ms) { + let id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout(id) { + cleared.push(id); + timers.delete(id); + }, + trigger(id) { + timers.get(id)?.fn(); + }, + get(id) { + return timers.get(id); + }, + get cleared() { + return cleared; + }, + }; +} + +describe('withTimeout', () => { + it('returns the operation result and clears the timeout', async () => { + let timers = createManualTimers(); + + let result = await withTimeout( + Promise.resolve('registered'), + 5000, + 'timed out', + timers + ); + + assert.strictEqual(result, 'registered'); + assert.deepStrictEqual(timers.cleared, [1]); + assert.strictEqual(timers.get(1), undefined); + }); + + it('rejects with the timeout error when the timer wins', async () => { + let timers = createManualTimers(); + let promise = withTimeout(new Promise(() => {}), 5000, 'timed out', timers); + + timers.trigger(1); + + await assert.rejects(promise, /timed out/); + assert.deepStrictEqual(timers.cleared, [1]); + }); + + it('passes through operation errors and clears the timeout', async () => { + let timers = createManualTimers(); + let error = new Error('registration failed'); + + await assert.rejects( + withTimeout(Promise.reject(error), 5000, 'timed out', timers), + error + ); + + assert.deepStrictEqual(timers.cleared, [1]); + }); +}); diff --git a/tests/utils/fetch-utils.test.js b/tests/utils/fetch-utils.test.js index 727c86de..6b787ef0 100644 --- a/tests/utils/fetch-utils.test.js +++ b/tests/utils/fetch-utils.test.js @@ -3,6 +3,49 @@ import http from 'node:http'; import { after, before, describe, it } from 'node:test'; import { fetchWithTimeout } from '../../src/utils/fetch-utils.js'; +function createManualTimers() { + let timers = new Map(); + let nextId = 1; + + return { + setTimeout(fn, ms) { + let id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + trigger(id) { + let timer = timers.get(id); + timers.delete(id); + timer?.fn(); + }, + get(id) { + return timers.get(id); + }, + }; +} + +function createAbortableFetch() { + let calls = []; + + return { + calls, + fetch(url, options) { + calls.push({ url, options }); + + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => { + let error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + }, + }; +} + describe('utils/fetch-utils', () => { let server; let serverPort; @@ -55,18 +98,26 @@ describe('utils/fetch-utils', () => { assert.strictEqual(response.status, 200); }); - it('aborts on timeout', async () => { - await assert.rejects( - fetchWithTimeout( - `http://127.0.0.1:${serverPort}/slow`, - {}, - 50 // 50ms timeout - ), - error => { - // AbortError or similar - return error.name === 'AbortError' || error.message.includes('abort'); - } - ); + it('aborts on timeout without waiting on wall-clock time', async () => { + let timers = createManualTimers(); + let abortableFetch = createAbortableFetch(); + + let request = fetchWithTimeout('/slow', {}, 50, { + fetch: abortableFetch.fetch, + timers, + }); + + assert.strictEqual(timers.get(1).ms, 50); + assert.strictEqual(abortableFetch.calls[0].url, '/slow'); + assert.strictEqual(abortableFetch.calls[0].options.signal.aborted, false); + + timers.trigger(1); + + await assert.rejects(request, error => { + assert.strictEqual(error.name, 'AbortError'); + return true; + }); + assert.strictEqual(timers.get(1), undefined); }); it('uses default timeout when not specified', async () => { diff --git a/tests/utils/patterns.test.js b/tests/utils/patterns.test.js new file mode 100644 index 00000000..b74347da --- /dev/null +++ b/tests/utils/patterns.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createWildcardMatcher, + escapeRegExp, +} from '../../src/utils/patterns.js'; + +describe('patterns', () => { + describe('escapeRegExp', () => { + it('escapes regular expression syntax characters', () => { + assert.strictEqual(escapeRegExp('a+b[c].png'), 'a\\+b\\[c\\]\\.png'); + }); + }); + + describe('createWildcardMatcher', () => { + it('treats non-wildcard regex syntax as literal text', () => { + let matches = createWildcardMatcher('card[primary].png'); + + assert.strictEqual(matches('card[primary].png'), true); + assert.strictEqual(matches('cardp.png'), false); + }); + + it('treats asterisks as wildcards', () => { + let matches = createWildcardMatcher('button-*'); + + assert.strictEqual(matches('button-primary'), true); + assert.strictEqual(matches('button-secondary'), true); + assert.strictEqual(matches('input-primary'), false); + }); + + it('can anchor wildcard matches to the whole value', () => { + let matches = createWildcardMatcher('*.js', { anchored: true }); + + assert.strictEqual(matches('app.js'), true); + assert.strictEqual(matches('app.js.map'), false); + }); + }); +}); diff --git a/tests/utils/screenshot-options.test.js b/tests/utils/screenshot-options.test.js new file mode 100644 index 00000000..dde796b3 --- /dev/null +++ b/tests/utils/screenshot-options.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { createScreenshotProperties } from '../../src/utils/screenshot-options.js'; + +describe('createScreenshotProperties', () => { + it('normalizes comparison options into the server properties payload', () => { + let properties = createScreenshotProperties({ + buildId: 'build-1', + browser: 'chromium', + properties: { url: '/checkout' }, + threshold: 0, + minClusterSize: 3, + fullPage: true, + }); + + assert.deepStrictEqual(properties, { + browser: 'chromium', + url: '/checkout', + threshold: 0, + minClusterSize: 3, + fullPage: true, + }); + }); + + it('lets dedicated comparison options override nested properties', () => { + let properties = createScreenshotProperties({ + threshold: 1, + minClusterSize: 2, + properties: { + threshold: 5, + minClusterSize: 10, + }, + }); + + assert.deepStrictEqual(properties, { + threshold: 1, + minClusterSize: 2, + }); + }); +});