Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions src/commands/finalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
148 changes: 148 additions & 0 deletions tests/commands/finalize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,160 @@ 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(
c => c.method === 'warn' && c.args[0].includes('API unavailable')
)
);
});

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')
)
);
});
});
});