diff --git a/src/auth/index.js b/src/auth/index.js index 8f8551fd..8d87b8e2 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -7,6 +7,12 @@ * - operations.js: Auth operations with dependency injection */ +// Re-export token store utilities for convenience +export { + clearAuthTokens, + getAuthTokens, + saveAuthTokens, +} from '../utils/global-config.js'; // HTTP client factory export { createAuthClient } from './client.js'; // Core pure functions @@ -22,7 +28,6 @@ export { parseAuthenticatedError, validateTokens, } from './core.js'; - // Auth operations (take dependencies as parameters) export { completeDeviceFlow, @@ -33,3 +38,15 @@ export { refresh, whoami, } from './operations.js'; + +/** + * Create a token store adapter from global-config functions + * Used by auth operations that need tokenStore parameter + */ +export function createTokenStore() { + return { + getTokens: getAuthTokens, + saveTokens: saveAuthTokens, + clearTokens: clearAuthTokens, + }; +} diff --git a/src/commands/doctor.js b/src/commands/doctor.js index 88edd4e7..0405275a 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.js @@ -1,6 +1,6 @@ import { URL } from 'node:url'; +import { createApiClient, getBuilds } from '../api/index.js'; import { ConfigError } from '../errors/vizzly-error.js'; -import { ApiService } from '../services/api-service.js'; import { loadConfig } from '../utils/config-loader.js'; import { getApiToken } from '../utils/environment-config.js'; import * as output from '../utils/output.js'; @@ -110,13 +110,13 @@ export async function doctorCommand(options = {}, globalOptions = {}) { } else { output.progress('Checking API connectivity...'); try { - const api = new ApiService({ + let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, command: 'doctor', }); // Minimal, read-only call - await api.getBuilds({ limit: 1 }); + await getBuilds(client, { limit: 1 }); diagnostics.connectivity.ok = true; output.success('API connectivity OK'); } catch (err) { diff --git a/src/commands/finalize.js b/src/commands/finalize.js index 927f7cfe..710326d8 100644 --- a/src/commands/finalize.js +++ b/src/commands/finalize.js @@ -1,4 +1,9 @@ -import { createServices } from '../services/index.js'; +/** + * Finalize command implementation + * Uses functional API operations directly + */ + +import { createApiClient, finalizeParallelBuild } from '../api/index.js'; import { loadConfig } from '../utils/config-loader.js'; import * as output from '../utils/output.js'; @@ -21,8 +26,8 @@ export async function finalizeCommand( try { // Load configuration with CLI overrides - const allOptions = { ...globalOptions, ...options }; - const config = await loadConfig(globalOptions.config, allOptions); + let allOptions = { ...globalOptions, ...options }; + let config = await loadConfig(globalOptions.config, allOptions); // Validate API token if (!config.apiKey) { @@ -40,15 +45,16 @@ export async function finalizeCommand( }); } - // Create services and get API service + // Call finalize endpoint via functional API output.startSpinner('Finalizing parallel build...'); - const services = createServices(config, 'finalize'); - const apiService = services.apiService; + let client = createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'finalize', + }); + let result = await finalizeParallelBuild(client, parallelId); output.stopSpinner(); - // Call finalize endpoint - const result = await apiService.finalizeParallelBuild(parallelId); - if (globalOptions.json) { output.data(result); } else { @@ -73,7 +79,7 @@ export async function finalizeCommand( * @param {Object} options - Command options */ export function validateFinalizeOptions(parallelId, _options) { - const errors = []; + let errors = []; if (!parallelId || parallelId.trim() === '') { errors.push('Parallel ID is required'); diff --git a/src/commands/login.js b/src/commands/login.js index 7f96d2a9..0055fa95 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -3,7 +3,13 @@ * Authenticates user via OAuth device flow */ -import { AuthService } from '../services/auth-service.js'; +import { + completeDeviceFlow, + createAuthClient, + createTokenStore, + initiateDeviceFlow, + pollDeviceAuthorization, +} from '../auth/index.js'; import { openBrowser } from '../utils/browser.js'; import { getApiUrl } from '../utils/environment-config.js'; import * as output from '../utils/output.js'; @@ -26,14 +32,15 @@ export async function loginCommand(options = {}, globalOptions = {}) { output.info('Starting Vizzly authentication...'); output.blank(); - // Create auth service - const authService = new AuthService({ + // Create auth client and token store + let client = createAuthClient({ baseUrl: options.apiUrl || getApiUrl(), }); + let tokenStore = createTokenStore(); // Initiate device flow output.startSpinner('Connecting to Vizzly...'); - const deviceFlow = await authService.initiateDeviceFlow(); + let deviceFlow = await initiateDeviceFlow(client); output.stopSpinner(); // Handle both snake_case and camelCase field names @@ -93,7 +100,7 @@ export async function loginCommand(options = {}, globalOptions = {}) { // Check authorization status output.startSpinner('Checking authorization status...'); - const pollResponse = await authService.pollDeviceAuthorization(deviceCode); + let pollResponse = await pollDeviceAuthorization(client, deviceCode); output.stopSpinner(); @@ -132,7 +139,7 @@ export async function loginCommand(options = {}, globalOptions = {}) { user: tokenData.user, organizations: tokenData.organizations, }; - await authService.completeDeviceFlow(tokens); + await completeDeviceFlow(tokenStore, tokens); // Display success message output.success('Successfully authenticated!'); diff --git a/src/commands/logout.js b/src/commands/logout.js index 678818b5..d9b697f0 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -3,9 +3,13 @@ * Clears stored authentication tokens */ -import { AuthService } from '../services/auth-service.js'; +import { + createAuthClient, + createTokenStore, + getAuthTokens, + logout, +} from '../auth/index.js'; import { getApiUrl } from '../utils/environment-config.js'; -import { getAuthTokens } from '../utils/global-config.js'; import * as output from '../utils/output.js'; /** @@ -33,11 +37,12 @@ export async function logoutCommand(options = {}, globalOptions = {}) { // Logout output.startSpinner('Logging out...'); - const authService = new AuthService({ + let client = createAuthClient({ baseUrl: options.apiUrl || getApiUrl(), }); + let tokenStore = createTokenStore(); - await authService.logout(); + await logout(client, tokenStore); output.stopSpinner(); output.success('Successfully logged out'); diff --git a/src/commands/project.js b/src/commands/project.js index 8179684f..01d25ad6 100644 --- a/src/commands/project.js +++ b/src/commands/project.js @@ -5,11 +5,15 @@ import { resolve } from 'node:path'; import readline from 'node:readline'; -import { AuthService } from '../services/auth-service.js'; +import { + createAuthClient, + createTokenStore, + getAuthTokens, + whoami, +} from '../auth/index.js'; import { getApiUrl } from '../utils/environment-config.js'; import { deleteProjectMapping, - getAuthTokens, getProjectMapping, getProjectMappings, saveProjectMapping, @@ -38,13 +42,14 @@ export async function projectSelectCommand(options = {}, globalOptions = {}) { process.exit(1); } - const authService = new AuthService({ + let client = createAuthClient({ baseUrl: options.apiUrl || getApiUrl(), }); + let tokenStore = createTokenStore(); // Get user info to show organizations output.startSpinner('Fetching organizations...'); - const userInfo = await authService.whoami(); + let userInfo = await whoami(client, tokenStore); output.stopSpinner(); if (!userInfo.organizations || userInfo.organizations.length === 0) { diff --git a/src/commands/run.js b/src/commands/run.js index 5966d09a..c47f0436 100644 --- a/src/commands/run.js +++ b/src/commands/run.js @@ -1,4 +1,20 @@ -import { createServices } from '../services/index.js'; +/** + * Run command implementation + * Uses functional operations directly - no class wrappers needed + */ + +import { spawn } from 'node:child_process'; +import { + createBuild as createApiBuild, + createApiClient, + finalizeBuild as finalizeApiBuild, + getBuild, +} from '../api/index.js'; +import { VizzlyError } from '../errors/vizzly-error.js'; +import { createServerManager } from '../server-manager/index.js'; +import { createBuildObject } from '../services/build-manager.js'; +import { createUploader } from '../services/uploader.js'; +import { finalizeBuild, runTests } from '../test-runner/index.js'; import { loadConfig } from '../utils/config-loader.js'; import { detectBranch, @@ -26,59 +42,73 @@ export async function runCommand( color: !globalOptions.noColor, }); - let testRunner = null; + let serverManager = null; + let testProcess = null; let buildId = null; let startTime = null; let isTddMode = false; + let config = null; // Ensure cleanup on exit - const cleanup = async () => { + let cleanup = async () => { output.cleanup(); - // Cancel test runner (kills process and stops server) - if (testRunner) { + // Kill test process if running + if (testProcess && !testProcess.killed) { + testProcess.kill('SIGKILL'); + } + + // Stop server + if (serverManager) { try { - await testRunner.cancel(); + await serverManager.stop(); } catch { // Silent fail } } // Finalize build if we have one - if (testRunner && buildId) { + if (buildId && config) { try { - const executionTime = Date.now() - (startTime || Date.now()); - await testRunner.finalizeBuild( + let executionTime = Date.now() - (startTime || Date.now()); + await finalizeBuild({ buildId, - isTddMode, - false, - executionTime - ); + tdd: isTddMode, + success: false, + executionTime, + config, + deps: { + serverManager, + createApiClient, + finalizeApiBuild, + output, + }, + }); } catch { // Silent fail on cleanup } } }; - const sigintHandler = async () => { + let sigintHandler = async () => { await cleanup(); process.exit(1); }; - const exitHandler = () => output.cleanup(); + let exitHandler = () => output.cleanup(); process.on('SIGINT', sigintHandler); process.on('exit', exitHandler); try { // Load configuration with CLI overrides - const allOptions = { ...globalOptions, ...options }; + let allOptions = { ...globalOptions, ...options }; output.debug('[RUN] Loading config', { hasToken: !!allOptions.token, }); - const config = await loadConfig(globalOptions.config, allOptions); + config = await loadConfig(globalOptions.config, allOptions); output.debug('[RUN] Config loaded', { hasApiKey: !!config.apiKey, @@ -110,11 +140,11 @@ export async function runCommand( } // Collect git metadata and build info - const branch = await detectBranch(options.branch); - const commit = await detectCommit(options.commit); - const message = options.message || (await detectCommitMessage()); - const buildName = await generateBuildNameWithGit(options.buildName); - const pullRequestNumber = detectPullRequestNumber(); + let branch = await detectBranch(options.branch); + let commit = await detectCommit(options.commit); + let message = options.message || (await detectCommitMessage()); + let buildName = await generateBuildNameWithGit(options.buildName); + let pullRequestNumber = detectPullRequestNumber(); if (globalOptions.verbose) { output.info('Configuration loaded'); @@ -131,9 +161,9 @@ export async function runCommand( }); } - // Create service container and get test runner service + // Create functional dependencies output.startSpinner('Initializing test runner...'); - const configWithVerbose = { + let configWithVerbose = { ...config, verbose: globalOptions.verbose, uploadAll: options.uploadAll || false, @@ -143,66 +173,26 @@ export async function runCommand( hasApiKey: !!configWithVerbose.apiKey, }); - const services = createServices(configWithVerbose, 'run'); - testRunner = services.testRunner; - output.stopSpinner(); - - // Track build URL for display - let buildUrl = null; - - // Set up event handlers - testRunner.on('progress', progressData => { - const { message: progressMessage } = progressData; - output.progress(progressMessage || 'Running tests...'); - }); - - testRunner.on('test-output', data => { - // In non-JSON mode, show test output directly - if (!globalOptions.json) { - output.stopSpinner(); - output.print(data.data); - } - }); + // Create server manager (functional object) + serverManager = createServerManager(configWithVerbose, {}); - testRunner.on('server-ready', serverInfo => { - if (globalOptions.verbose) { - output.info(`Screenshot server running on port ${serverInfo.port}`); - output.debug('Server details', serverInfo); - } - }); - - testRunner.on('screenshot-captured', screenshotInfo => { - output.info(`Vizzly: Screenshot captured - ${screenshotInfo.name}`); - }); - - testRunner.on('build-created', buildInfo => { - buildUrl = buildInfo.url; - buildId = buildInfo.buildId; - if (globalOptions.verbose) { - output.info(`Build created: ${buildInfo.buildId} - ${buildInfo.name}`); - } - if (buildUrl) { - output.info(`Vizzly: ${buildUrl}`); - } - }); + // Create build manager (functional object) + let buildManager = { + async createBuild(buildOptions) { + return createBuildObject(buildOptions); + }, + }; - testRunner.on('build-failed', buildError => { - output.error('Failed to create build', buildError); - }); + // Create uploader for --wait functionality + let uploader = createUploader({ ...configWithVerbose, command: 'run' }); - testRunner.on('error', error => { - output.stopSpinner(); - output.error('Test runner error occurred', error); - }); + output.stopSpinner(); - testRunner.on('build-finalize-failed', errorInfo => { - output.warn( - `Failed to finalize build ${errorInfo.buildId}: ${errorInfo.error}` - ); - }); + // Track build URL for display + let buildUrl = null; // Prepare run options - const runOptions = { + let runOptions = { testCommand, port: config.server.port, timeout: config.server.timeout, @@ -227,7 +217,45 @@ export async function runCommand( let result; try { - result = await testRunner.run(runOptions); + result = await runTests({ + runOptions, + config: configWithVerbose, + deps: { + serverManager, + buildManager, + spawn: (command, spawnOptions) => { + let proc = spawn(command, spawnOptions); + testProcess = proc; + return proc; + }, + createApiClient, + createApiBuild, + getBuild, + finalizeApiBuild, + createError: (message, code) => new VizzlyError(message, code), + output, + onBuildCreated: data => { + buildUrl = data.url; + buildId = data.buildId; + if (globalOptions.verbose) { + output.info(`Build created: ${data.buildId}`); + } + if (buildUrl) { + output.info(`Vizzly: ${buildUrl}`); + } + }, + onServerReady: data => { + if (globalOptions.verbose) { + output.info(`Screenshot server running on port ${data.port}`); + } + }, + onFinalizeFailed: data => { + output.warn( + `Failed to finalize build ${data.buildId}: ${data.error}` + ); + }, + }, + }); // Store buildId for cleanup purposes if (result.buildId) { @@ -255,8 +283,8 @@ export async function runCommand( error.code === 'TEST_COMMAND_INTERRUPTED' ) { // Extract exit code from error message if available - const exitCodeMatch = error.message.match(/exited with code (\d+)/); - const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1; + let exitCodeMatch = error.message.match(/exited with code (\d+)/); + let exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1; output.error('Test run failed'); return { success: false, exitCode }; @@ -274,8 +302,7 @@ export async function runCommand( output.info('Waiting for build completion...'); output.startSpinner('Processing comparisons...'); - const { uploader } = services; - const buildResult = await uploader.waitForBuild(result.buildId); + let buildResult = await uploader.waitForBuild(result.buildId); output.success('Build processing completed'); @@ -318,35 +345,35 @@ export async function runCommand( * @param {Object} options - Command options */ export function validateRunOptions(testCommand, options) { - const errors = []; + let errors = []; if (!testCommand || testCommand.trim() === '') { errors.push('Test command is required'); } if (options.port) { - const port = parseInt(options.port, 10); + let port = parseInt(options.port, 10); if (Number.isNaN(port) || port < 1 || port > 65535) { errors.push('Port must be a valid number between 1 and 65535'); } } if (options.timeout) { - const timeout = parseInt(options.timeout, 10); + let timeout = parseInt(options.timeout, 10); if (Number.isNaN(timeout) || timeout < 1000) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.batchSize !== undefined) { - const n = parseInt(options.batchSize, 10); + let n = parseInt(options.batchSize, 10); if (!Number.isFinite(n) || n <= 0) { errors.push('Batch size must be a positive integer'); } } if (options.uploadTimeout !== undefined) { - const n = parseInt(options.uploadTimeout, 10); + let n = parseInt(options.uploadTimeout, 10); if (!Number.isFinite(n) || n <= 0) { errors.push('Upload timeout must be a positive integer (milliseconds)'); } diff --git a/src/commands/status.js b/src/commands/status.js index b11721e3..4365a40d 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -1,4 +1,9 @@ -import { createServices } from '../services/index.js'; +/** + * Status command implementation + * Uses functional API operations directly + */ + +import { createApiClient, getBuild } from '../api/index.js'; import { loadConfig } from '../utils/config-loader.js'; import { getApiUrl } from '../utils/environment-config.js'; import * as output from '../utils/output.js'; @@ -20,8 +25,8 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { output.info(`Checking status for build: ${buildId}`); // Load configuration with CLI overrides - const allOptions = { ...globalOptions, ...options }; - const config = await loadConfig(globalOptions.config, allOptions); + let allOptions = { ...globalOptions, ...options }; + let config = await loadConfig(globalOptions.config, allOptions); // Validate API token if (!config.apiKey) { @@ -31,17 +36,18 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { process.exit(1); } - // Get API service + // Get build details via functional API output.startSpinner('Fetching build status...'); - const services = createServices(config, 'status'); - const { apiService } = services; - - // Get build details via unified ApiService - const buildStatus = await apiService.getBuild(buildId); + let client = createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'status', + }); + let buildStatus = await getBuild(client, buildId); output.stopSpinner(); // Extract build data from API response - const build = buildStatus.build || buildStatus; + let build = buildStatus.build || buildStatus; // Display build summary output.success(`Build: ${build.name || build.id}`); @@ -90,9 +96,9 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { } // Show build URL if we can construct it - const baseUrl = config.baseUrl || getApiUrl(); + let baseUrl = config.baseUrl || getApiUrl(); if (baseUrl && build.project_id) { - const buildUrl = + let buildUrl = baseUrl.replace('/api', '') + `/projects/${build.project_id}/builds/${build.id}`; output.info(`View Build: ${buildUrl}`); @@ -100,7 +106,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { // Output JSON data for --json mode if (globalOptions.json) { - const statusData = { + let statusData = { buildId: build.id, status: build.status, name: build.name, @@ -159,10 +165,10 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { // Show progress if build is still processing if (build.status === 'processing' || build.status === 'pending') { - const totalJobs = + let totalJobs = build.completed_jobs + build.failed_jobs + build.processing_screenshots; if (totalJobs > 0) { - const progress = (build.completed_jobs + build.failed_jobs) / totalJobs; + let progress = (build.completed_jobs + build.failed_jobs) / totalJobs; output.info(`Progress: ${Math.round(progress * 100)}% complete`); } } @@ -185,7 +191,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { * @param {Object} options - Command options */ export function validateStatusOptions(buildId) { - const errors = []; + let errors = []; if (!buildId || buildId.trim() === '') { errors.push('Build ID is required'); diff --git a/src/commands/tdd.js b/src/commands/tdd.js index 47c0644c..203414d0 100644 --- a/src/commands/tdd.js +++ b/src/commands/tdd.js @@ -1,4 +1,19 @@ -import { createServices } from '../services/index.js'; +/** + * TDD command implementation + * Uses functional operations directly - no class wrappers needed + */ + +import { spawn } from 'node:child_process'; +import { + createBuild as createApiBuild, + createApiClient, + finalizeBuild as finalizeApiBuild, + getBuild, +} from '../api/index.js'; +import { VizzlyError } from '../errors/vizzly-error.js'; +import { createServerManager } from '../server-manager/index.js'; +import { createBuildObject } from '../services/build-manager.js'; +import { initializeDaemon, runTests } from '../test-runner/index.js'; import { loadConfig } from '../utils/config-loader.js'; import { detectBranch, detectCommit } from '../utils/git.js'; import * as output from '../utils/output.js'; @@ -21,27 +36,31 @@ export async function tddCommand( color: !globalOptions.noColor, }); - let testRunner = null; + let serverManager = null; + let testProcess = null; let isCleanedUp = false; // Create cleanup function that can be called by the caller - const cleanup = async () => { + let cleanup = async () => { if (isCleanedUp) return; isCleanedUp = true; output.cleanup(); - if (testRunner?.cancel) { - await testRunner.cancel(); + if (testProcess && !testProcess.killed) { + testProcess.kill('SIGKILL'); + } + if (serverManager) { + await serverManager.stop(); } }; try { // Load configuration with CLI overrides - const allOptions = { ...globalOptions, ...options }; - const config = await loadConfig(globalOptions.config, allOptions); + let allOptions = { ...globalOptions, ...options }; + let config = await loadConfig(globalOptions.config, allOptions); // Dev mode works locally by default - only needs token for baseline download - const needsToken = options.baselineBuild || options.baselineComparison; + let needsToken = options.baselineBuild || options.baselineComparison; if (!config.apiKey && needsToken) { throw new Error( @@ -53,12 +72,12 @@ export async function tddCommand( config.allowNoToken = true; // Collect git metadata - const branch = await detectBranch(options.branch); - const commit = await detectCommit(options.commit); + let branch = await detectBranch(options.branch); + let commit = await detectCommit(options.commit); // Show header (skip in daemon mode) if (!options.daemon) { - const mode = config.apiKey ? 'local' : 'local'; + let mode = config.apiKey ? 'local' : 'local'; output.header('tdd', mode); // Show config in verbose mode @@ -69,75 +88,53 @@ export async function tddCommand( }); } - // Create services + // Create functional dependencies output.startSpinner('Initializing TDD server...'); - const configWithVerbose = { ...config, verbose: globalOptions.verbose }; - const services = createServices(configWithVerbose, 'tdd'); - testRunner = services.testRunner; - output.stopSpinner(); - - // Set up event handlers for user feedback - testRunner.on('progress', progressData => { - const { message: progressMessage } = progressData; - output.progress(progressMessage || 'Running tests...'); - }); + let configWithVerbose = { ...config, verbose: globalOptions.verbose }; - testRunner.on('test-output', data => { - // In non-JSON mode, show test output directly - if (!globalOptions.json) { - output.stopSpinner(); - output.print(data.data); - } - }); - - testRunner.on('server-ready', serverInfo => { - // Only show in non-daemon mode (daemon shows its own startup message) - if (!options.daemon) { - output.debug('server', `listening on :${serverInfo.port}`); - } - }); - - testRunner.on('screenshot-captured', screenshotInfo => { - output.debug('capture', screenshotInfo.name); - }); + // Create server manager (functional object) + serverManager = createServerManager(configWithVerbose, {}); - testRunner.on('comparison-result', comparisonInfo => { - const { name, status, pixelDifference } = comparisonInfo; - if (status === 'passed') { - output.debug('compare', `${name} passed`); - } else if (status === 'failed') { - output.warn(`${name}: ${pixelDifference}% difference`); - } else if (status === 'new') { - output.debug('compare', `${name} (new baseline)`); - } - }); + // Create build manager (functional object that provides the interface runTests expects) + let buildManager = { + async createBuild(buildOptions) { + return createBuildObject(buildOptions); + }, + }; - testRunner.on('error', error => { - output.error('Test runner error', error); - }); + output.stopSpinner(); - const runOptions = { + let runOptions = { testCommand, port: config.server.port, timeout: config.server.timeout, tdd: true, - daemon: options.daemon || false, // Daemon mode flag - setBaseline: options.setBaseline || false, // Pass through baseline update mode + daemon: options.daemon || false, + setBaseline: options.setBaseline || false, branch, commit, environment: config.build.environment, threshold: config.comparison.threshold, - allowNoToken: config.allowNoToken || false, // Pass through the allow-no-token setting + allowNoToken: config.allowNoToken || false, baselineBuildId: config.baselineBuildId, baselineComparisonId: config.baselineComparisonId, - wait: false, // No build to wait for in dev mode + wait: false, }; // In daemon mode, just start the server without running tests if (options.daemon) { - await testRunner.initialize(runOptions); + await initializeDaemon({ + initOptions: runOptions, + deps: { + serverManager, + createError: (message, code) => new VizzlyError(message, code), + output, + onServerReady: data => { + output.debug('server', `listening on :${data.port}`); + }, + }, + }); - // Return immediately so daemon can set up its lifecycle return { result: { success: true, @@ -150,19 +147,47 @@ export async function tddCommand( // Normal dev mode - run tests output.debug('run', testCommand); - const runResult = await testRunner.run(runOptions); + + let runResult = await runTests({ + runOptions, + config: configWithVerbose, + deps: { + serverManager, + buildManager, + spawn: (command, spawnOptions) => { + let proc = spawn(command, spawnOptions); + testProcess = proc; + return proc; + }, + createApiClient, + createApiBuild, + getBuild, + finalizeApiBuild, + createError: (message, code) => new VizzlyError(message, code), + output, + onBuildCreated: data => { + output.debug('build', `created ${data.buildId?.substring(0, 8)}`); + }, + onServerReady: data => { + output.debug('server', `listening on :${data.port}`); + }, + onFinalizeFailed: data => { + output.warn(`Failed to finalize build: ${data.error}`); + }, + }, + }); // Show summary - const { screenshotsCaptured, comparisons } = runResult; + let { screenshotsCaptured, comparisons } = runResult; // Determine success based on comparison results - const hasFailures = + let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed'); if (comparisons && comparisons.length > 0) { - const passed = comparisons.filter(c => c.status === 'passed').length; - const failed = comparisons.filter(c => c.status === 'failed').length; + let passed = comparisons.filter(c => c.status === 'passed').length; + let failed = comparisons.filter(c => c.status === 'failed').length; if (hasFailures) { output.error( @@ -180,7 +205,6 @@ export async function tddCommand( ); } - // Return result and cleanup function return { result: { success: !hasFailures, @@ -208,28 +232,28 @@ export async function tddCommand( * @param {Object} options - Command options */ export function validateTddOptions(testCommand, options) { - const errors = []; + let errors = []; if (!testCommand || testCommand.trim() === '') { errors.push('Test command is required'); } if (options.port) { - const port = parseInt(options.port, 10); + let port = parseInt(options.port, 10); if (Number.isNaN(port) || port < 1 || port > 65535) { errors.push('Port must be a valid number between 1 and 65535'); } } if (options.timeout) { - const timeout = parseInt(options.timeout, 10); + let timeout = parseInt(options.timeout, 10); if (Number.isNaN(timeout) || timeout < 1000) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.threshold !== undefined) { - const threshold = parseFloat(options.threshold); + let threshold = parseFloat(options.threshold); if (Number.isNaN(threshold) || threshold < 0) { errors.push( 'Threshold must be a non-negative number (CIEDE2000 Delta E)' diff --git a/src/commands/upload.js b/src/commands/upload.js index 9a10c01c..c3992c76 100644 --- a/src/commands/upload.js +++ b/src/commands/upload.js @@ -1,5 +1,9 @@ -import { ApiService } from '../services/api-service.js'; -import { createServices } from '../services/index.js'; +import { + createApiClient, + finalizeBuild, + getTokenContext, +} from '../api/index.js'; +import { createUploader } from '../services/uploader.js'; import { loadConfig } from '../utils/config-loader.js'; import { detectBranch, @@ -19,14 +23,14 @@ import * as output from '../utils/output.js'; */ async function constructBuildUrl(buildId, apiUrl, apiToken) { try { - const apiService = new ApiService({ + let client = createApiClient({ baseUrl: apiUrl, token: apiToken, command: 'upload', }); - const tokenContext = await apiService.getTokenContext(); - const baseUrl = apiUrl.replace(/\/api.*$/, ''); + let tokenContext = await getTokenContext(client); + let baseUrl = apiUrl.replace(/\/api.*$/, ''); if (tokenContext.organization?.slug && tokenContext.project?.slug) { return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${buildId}`; @@ -97,10 +101,9 @@ export async function uploadCommand( }); } - // Get uploader service + // Create uploader output.startSpinner('Initializing uploader...'); - const services = createServices(config, 'upload'); - const uploader = services.uploader; + let uploader = createUploader({ ...config, command: 'upload' }); // Prepare upload options with progress callback const uploadOptions = { @@ -151,13 +154,13 @@ export async function uploadCommand( if (result.buildId) { output.progress('Finalizing build...'); try { - const apiService = new ApiService({ + let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, command: 'upload', }); - const executionTime = Date.now() - uploadStartTime; - await apiService.finalizeBuild(result.buildId, true, executionTime); + let executionTime = Date.now() - uploadStartTime; + await finalizeBuild(client, result.buildId, true, executionTime); } catch (error) { output.warn(`Failed to finalize build: ${error.message}`); } @@ -208,13 +211,13 @@ export async function uploadCommand( // Mark build as failed if we have a buildId and config if (buildId && config) { try { - const apiService = new ApiService({ + let client = createApiClient({ baseUrl: config.apiUrl, token: config.apiKey, command: 'upload', }); - const executionTime = Date.now() - uploadStartTime; - await apiService.finalizeBuild(buildId, false, executionTime); + let executionTime = Date.now() - uploadStartTime; + await finalizeBuild(client, buildId, false, executionTime); } catch { // Silent fail on cleanup } diff --git a/src/commands/whoami.js b/src/commands/whoami.js index 43f701d3..e07acb03 100644 --- a/src/commands/whoami.js +++ b/src/commands/whoami.js @@ -3,9 +3,13 @@ * Shows current user and authentication status */ -import { AuthService } from '../services/auth-service.js'; +import { + createAuthClient, + createTokenStore, + getAuthTokens, + whoami, +} from '../auth/index.js'; import { getApiUrl } from '../utils/environment-config.js'; -import { getAuthTokens } from '../utils/global-config.js'; import * as output from '../utils/output.js'; /** @@ -39,11 +43,12 @@ export async function whoamiCommand(options = {}, globalOptions = {}) { // Get current user info output.startSpinner('Fetching user information...'); - const authService = new AuthService({ + let client = createAuthClient({ baseUrl: options.apiUrl || getApiUrl(), }); + let tokenStore = createTokenStore(); - const response = await authService.whoami(); + let response = await whoami(client, tokenStore); output.stopSpinner(); diff --git a/src/server-manager/index.js b/src/server-manager/index.js index c77fd8d8..8a8d98c2 100644 --- a/src/server-manager/index.js +++ b/src/server-manager/index.js @@ -6,7 +6,15 @@ * - operations.js: Server operations with dependency injection */ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createApiClient } from '../api/index.js'; +import { createApiHandler } from '../server/handlers/api-handler.js'; +import { createTddHandler } from '../server/handlers/tdd-handler.js'; +import { createHttpServer } from '../server/http-server.js'; + // Core pure functions +import { buildServerInterface } from './core.js'; + export { buildClientOptions, buildServerInfo, @@ -20,6 +28,8 @@ export { } from './core.js'; // Server operations (take dependencies as parameters) +import { getTddResults, startServer, stopServer } from './operations.js'; + export { getTddResults, removeServerJson, @@ -27,3 +37,57 @@ export { stopServer, writeServerJson, } from './operations.js'; + +/** + * Create a server manager object that provides the interface commands expect. + * This is a thin functional wrapper that encapsulates the server lifecycle. + * + * @param {Object} config - Configuration object + * @param {Object} [services={}] - Optional services object + * @returns {Object} Server manager with start/stop/getTddResults methods + */ +export function createServerManager(config, services = {}) { + let httpServer = null; + let handler = null; + + let deps = { + createHttpServer, + createTddHandler, + createApiHandler, + createApiClient, + fs: { mkdirSync, writeFileSync, existsSync, unlinkSync }, + }; + + return { + async start(buildId, tddMode, setBaseline) { + let result = await startServer({ + config, + buildId, + tddMode, + setBaseline, + projectRoot: process.cwd(), + services, + deps, + }); + httpServer = result.httpServer; + handler = result.handler; + }, + + async stop() { + await stopServer({ + httpServer, + handler, + projectRoot: process.cwd(), + deps, + }); + }, + + async getTddResults() { + return getTddResults({ tddMode: true, handler }); + }, + + get server() { + return buildServerInterface({ handler, httpServer }); + }, + }; +}