@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
299 lines (287 loc) • 9.39 kB
JavaScript
/**
* Test Runner Service
* Orchestrates the test execution flow
*/
import { EventEmitter } from 'events';
import { VizzlyError } from '../errors/vizzly-error.js';
import { spawn } from 'child_process';
import * as output from '../utils/output.js';
export class TestRunner extends EventEmitter {
constructor(config, buildManager, serverManager, tddService) {
super();
this.config = config;
this.buildManager = buildManager;
this.serverManager = serverManager;
this.tddService = tddService;
this.testProcess = null;
}
/**
* Initialize server for daemon mode (no test execution)
* @param {Object} options - Options for server initialization
*/
async initialize(options) {
const {
tdd,
daemon
} = options;
if (!tdd || !daemon) {
throw new VizzlyError('Initialize method is only for TDD daemon mode', 'INVALID_MODE');
}
try {
// Start server manager for daemon mode
await this.serverManager.start(null, tdd, options.setBaseline);
this.emit('server-ready', {
port: options.port,
mode: 'daemon',
tdd: true
});
} catch (error) {
output.error('Failed to initialize TDD daemon server:', error);
throw error;
}
}
async run(options) {
const {
testCommand,
tdd,
allowNoToken
} = options;
const startTime = Date.now();
let buildId = null;
if (!testCommand) {
throw new VizzlyError('No test command provided', 'TEST_COMMAND_MISSING');
}
// If no token is allowed and not in TDD mode, just run the command without Vizzly integration
if (allowNoToken && !this.config.apiKey && !tdd) {
const env = {
...process.env,
VIZZLY_ENABLED: 'false'
};
await this.executeTestCommand(testCommand, env);
return {
testsPassed: 1,
testsFailed: 0,
screenshotsCaptured: 0
};
}
let buildUrl = null;
let screenshotCount = 0;
let testSuccess = false;
let testError = null;
try {
// Create build based on mode
buildId = await this.createBuild(options, tdd);
if (!tdd && buildId) {
// Get build URL for API mode
const apiService = await this.createApiService();
if (apiService) {
try {
const build = await apiService.getBuild(buildId);
buildUrl = build.url;
if (buildUrl) {
output.info(`Build URL: ${buildUrl}`);
}
} catch (error) {
output.debug('build', 'could not retrieve url', {
error: error.message
});
}
}
}
// Start server with appropriate handler
await this.serverManager.start(buildId, tdd, options.setBaseline);
const env = {
...process.env,
VIZZLY_SERVER_URL: `http://localhost:${this.config.server.port}`,
VIZZLY_BUILD_ID: buildId,
VIZZLY_ENABLED: 'true',
VIZZLY_SET_BASELINE: options.setBaseline || options['set-baseline'] ? 'true' : 'false'
};
try {
await this.executeTestCommand(testCommand, env);
testSuccess = true;
} catch (error) {
testError = error;
testSuccess = false;
}
} catch (error) {
// Error in setup phase
testError = error;
testSuccess = false;
}
// Get TDD results before stopping the server (comparisons, screenshot count)
let tddResults = null;
if (tdd) {
try {
tddResults = await this.serverManager.getTddResults();
if (tddResults) {
screenshotCount = tddResults.total || 0;
}
} catch (tddError) {
output.debug('tdd', 'failed to get results', {
error: tddError.message
});
}
}
// Always finalize the build and stop the server (cleanup phase)
try {
const executionTime = Date.now() - startTime;
if (buildId) {
try {
await this.finalizeBuild(buildId, tdd, testSuccess, executionTime);
} catch (finalizeError) {
output.error('Failed to finalize build:', finalizeError);
}
}
// In API mode, get actual screenshot count from handler after flush
if (!tdd && this.serverManager.server?.getScreenshotCount) {
screenshotCount = this.serverManager.server.getScreenshotCount(buildId) || 0;
}
} finally {
// Always stop the server, even if finalization fails
try {
await this.serverManager.stop();
} catch (stopError) {
output.error('Failed to stop server:', stopError);
}
}
// If there was a test error, throw it now (after cleanup)
if (testError) {
output.error('Test run failed:', testError);
throw testError;
}
return {
buildId: buildId,
url: buildUrl,
testsPassed: testSuccess ? 1 : 0,
testsFailed: testSuccess ? 0 : 1,
screenshotsCaptured: screenshotCount,
comparisons: tddResults?.comparisons || null,
failed: (tddResults?.failed || 0) > 0
};
}
async createBuild(options, tdd) {
if (tdd) {
// TDD mode: create local build
const build = await this.buildManager.createBuild(options);
output.debug('build', `created ${build.id.substring(0, 8)}`);
return build.id;
} else {
// API mode: create build via API
const apiService = await this.createApiService();
if (apiService) {
let buildPayload = {
name: options.buildName || `Test Run ${new Date().toISOString()}`,
branch: options.branch || 'main',
environment: options.environment || 'test',
commit_sha: options.commit,
commit_message: options.message,
github_pull_request_number: options.pullRequestNumber,
parallel_id: options.parallelId
};
// Only include metadata if we have meaningful config to send
if (this.config.comparison?.threshold != null) {
buildPayload.metadata = {
comparison: {
threshold: this.config.comparison.threshold
}
};
}
const buildResult = await apiService.createBuild(buildPayload);
output.debug('build', `created ${buildResult.id}`);
// Emit build created event
this.emit('build-created', {
buildId: buildResult.id,
url: buildResult.url,
name: buildResult.name || options.buildName
});
return buildResult.id;
} else {
throw new VizzlyError('No API key available for build creation', 'API_KEY_MISSING');
}
}
}
async createApiService() {
if (!this.config.apiKey) return null;
const {
ApiService
} = await import('./api-service.js');
return new ApiService({
...this.config,
command: 'run',
uploadAll: this.config.uploadAll
});
}
async finalizeBuild(buildId, isTddMode, success, executionTime) {
if (!buildId) {
return;
}
try {
if (isTddMode) {
// TDD mode: use server handler to finalize (local-only)
if (this.serverManager.server?.finishBuild) {
await this.serverManager.server.finishBuild(buildId);
output.debug('build', `finalized`, {
success
});
}
} else {
// API mode: flush uploads first, then finalize build
if (this.serverManager.server?.finishBuild) {
await this.serverManager.server.finishBuild(buildId);
}
// Then update build status via API
const apiService = await this.createApiService();
if (apiService) {
await apiService.finalizeBuild(buildId, success, executionTime);
output.debug('build', 'finalized via api', {
success
});
} else {
output.warn(`No API service available to finalize build ${buildId}`);
}
}
} catch (error) {
// Don't fail the entire run if build finalization fails
output.warn(`Failed to finalize build ${buildId}:`, error.message);
// Emit event for UI handling
this.emit('build-finalize-failed', {
buildId,
error: error.message,
stack: error.stack
});
}
}
async executeTestCommand(testCommand, env) {
return new Promise((resolve, reject) => {
// Use shell to execute the full command string
this.testProcess = spawn(testCommand, {
env,
stdio: 'inherit',
shell: true
});
this.testProcess.on('error', error => {
reject(new VizzlyError(`Failed to run test command: ${error.message}`), 'TEST_COMMAND_FAILED');
});
this.testProcess.on('exit', (code, signal) => {
// If process was killed by SIGINT, treat as interruption
if (signal === 'SIGINT') {
reject(new VizzlyError('Test command was interrupted', 'TEST_COMMAND_INTERRUPTED'));
} else if (code !== 0) {
reject(new VizzlyError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
} else {
resolve();
}
});
});
}
async cancel() {
if (this.testProcess && !this.testProcess.killed) {
this.testProcess.kill('SIGKILL');
}
// Stop server manager if running
if (this.serverManager) {
await this.serverManager.stop();
}
}
}