diff --git a/src/cli.js b/src/cli.js index 5b3ed50..e7ba8a3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -236,6 +236,10 @@ program .option('--json', 'Machine-readable output') .option('--color', 'Force colored output (even in non-TTY)') .option('--no-color', 'Disable colored output') + .option( + '--strict', + 'Fail on any error (default: be resilient, warn on non-critical issues)' + ) .configureHelp({ formatHelp }); // Load plugins before defining commands diff --git a/src/commands/finalize.js b/src/commands/finalize.js index 3028732..f9832b8 100644 --- a/src/commands/finalize.js +++ b/src/commands/finalize.js @@ -11,6 +11,12 @@ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import * as defaultOutput from '../utils/output.js'; import { writeSession as defaultWriteSession } from '../utils/session.js'; +let MISSING_BUILD_HINTS = [ + ' • No screenshots were uploaded with this parallel-id', + ' • Tests were skipped or failed before capturing screenshots', + ' • The parallel-id does not match what was used during test runs', +]; + /** * Finalize command implementation * @param {string} parallelId - Parallel ID to finalize @@ -96,11 +102,49 @@ export async function finalizeCommand( } catch (error) { output.stopSpinner(); - // Don't fail CI for Vizzly infrastructure issues (5xx errors) let status = error.context?.status; + + // Don't fail CI for Vizzly infrastructure issues (5xx errors) + // Note: --strict does NOT affect 5xx handling - infrastructure issues are out of user's control if (status >= 500) { output.warn('Vizzly API unavailable - finalize skipped.'); - return { success: true, result: { skipped: true } }; + return { + success: true, + result: { skipped: true, reason: 'api-unavailable' }, + }; + } + + // Handle missing builds gracefully (404 errors) + // This happens when: no screenshots were uploaded, tests were skipped, or parallel-id doesn't exist + if (status === 404) { + let isStrict = globalOptions.strict; + + if (isStrict) { + output.error(`No build found for parallel ID: ${parallelId}`); + output.blank(); + output.info('This can happen when:'); + for (let hint of MISSING_BUILD_HINTS) { + output.info(hint); + } + exit(1); + return { success: false, reason: 'no-build-found', error }; + } + + // Non-strict mode: warn but don't fail CI + output.warn( + `No build found for parallel ID: ${parallelId} - finalize skipped.` + ); + if (globalOptions.verbose) { + output.info('Possible reasons:'); + for (let hint of MISSING_BUILD_HINTS) { + output.info(hint); + } + output.info('Use --strict flag to fail CI when no build is found.'); + } + return { + success: true, + result: { skipped: true, reason: 'no-build-found' }, + }; } output.error('Failed to finalize parallel build', error); diff --git a/tests/commands/finalize.test.js b/tests/commands/finalize.test.js index 29fd7fd..01303aa 100644 --- a/tests/commands/finalize.test.js +++ b/tests/commands/finalize.test.js @@ -332,6 +332,7 @@ describe('commands/finalize', () => { assert.strictEqual(result.success, true); assert.strictEqual(result.result.skipped, true); + assert.strictEqual(result.result.reason, 'api-unavailable'); assert.strictEqual(exitCode, null); assert.ok( output.calls.some( @@ -339,5 +340,152 @@ describe('commands/finalize', () => { ) ); }); + + it('does not fail CI on 5xx even with --strict flag', async () => { + let output = createMockOutput(); + let exitCode = null; + + let apiError = new Error('API request failed: 503 - Service Unavailable'); + apiError.context = { status: 503 }; + + let result = await finalizeCommand( + 'parallel-123', + {}, + { strict: true }, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({ request: async () => ({}) }), + finalizeParallelBuild: async () => { + throw apiError; + }, + output, + exit: code => { + exitCode = code; + }, + } + ); + + // 5xx errors should ALWAYS be resilient, even with --strict + // Infrastructure issues are out of user's control + assert.strictEqual(result.success, true); + assert.strictEqual(result.result.skipped, true); + assert.strictEqual(result.result.reason, 'api-unavailable'); + assert.strictEqual(exitCode, null); + }); + + it('does not fail CI when no build found (404) in non-strict mode', async () => { + let output = createMockOutput(); + let exitCode = null; + + let apiError = new Error('API request failed: 404 - Not Found'); + apiError.context = { status: 404 }; + + let result = await finalizeCommand( + 'parallel-123', + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({ request: async () => ({}) }), + finalizeParallelBuild: async () => { + throw apiError; + }, + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.result.skipped, true); + assert.strictEqual(result.result.reason, 'no-build-found'); + assert.strictEqual(exitCode, null); + assert.ok( + output.calls.some( + c => c.method === 'warn' && c.args[0].includes('No build found') + ) + ); + }); + + it('fails CI when no build found (404) in strict mode', async () => { + let output = createMockOutput(); + let exitCode = null; + + let apiError = new Error('API request failed: 404 - Not Found'); + apiError.context = { status: 404 }; + + let result = await finalizeCommand( + 'parallel-123', + {}, + { strict: true }, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({ request: async () => ({}) }), + finalizeParallelBuild: async () => { + throw apiError; + }, + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.reason, 'no-build-found'); + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + c => c.method === 'error' && c.args[0].includes('No build found') + ) + ); + }); + + it('shows verbose hints when no build found in non-strict mode', async () => { + let output = createMockOutput(); + + let apiError = new Error('API request failed: 404 - Not Found'); + apiError.context = { status: 404 }; + + await finalizeCommand( + 'parallel-123', + {}, + { verbose: true }, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({ request: async () => ({}) }), + finalizeParallelBuild: async () => { + throw apiError; + }, + output, + exit: () => {}, + } + ); + + // Should show helpful hints in verbose mode + assert.ok( + output.calls.some( + c => c.method === 'info' && c.args[0].includes('Possible reasons') + ) + ); + assert.ok( + output.calls.some( + c => c.method === 'info' && c.args[0].includes('--strict') + ) + ); + }); }); });