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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules/
# Build outputs
dist/
!test-site/dist/
.build/

# Coverage reports
coverage/
Expand Down
4 changes: 3 additions & 1 deletion src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export function createApiClient(options = {}) {

// Other errors
let error = parseApiError(response.status, errorBody, url);
throw new VizzlyError(error.message, error.code);
throw new VizzlyError(error.message, error.code, {
status: error.status,
});
}

return response.json();
Expand Down
8 changes: 8 additions & 0 deletions src/commands/finalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export async function finalizeCommand(
return { success: true, result };
} catch (error) {
output.stopSpinner();

// Don't fail CI for Vizzly infrastructure issues (5xx errors)
let status = error.context?.status;
if (status >= 500) {
output.warn('Vizzly API unavailable - finalize skipped.');
return { success: true, result: { skipped: true } };
}

output.error('Failed to finalize parallel build', error);
exit(1);
return { success: false, error };
Expand Down
10 changes: 10 additions & 0 deletions src/commands/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,16 @@ export async function runCommand(
} catch (error) {
output.stopSpinner();

// Don't fail CI for Vizzly infrastructure issues (5xx errors)
let status = error.context?.status;
if (status >= 500) {
output.warn(
'Vizzly API unavailable - visual tests skipped. Your tests still ran.'
);
output.debug('api', 'API error details:', { error: error.message });
return { success: true, result: { skipped: true } };
}

// Provide more context about where the error occurred
let errorContext = 'Test run failed';
if (error.message?.includes('build')) {
Expand Down
45 changes: 37 additions & 8 deletions src/commands/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,38 @@
* Uses functional API operations directly
*/

import { createApiClient, getBuild, getPreviewInfo } 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';
import {
createApiClient as defaultCreateApiClient,
getBuild as defaultGetBuild,
getPreviewInfo as defaultGetPreviewInfo,
} from '../api/index.js';
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
import * as defaultOutput from '../utils/output.js';

/**
* Status command implementation
* @param {string} buildId - Build ID to check status for
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
* @param {Object} deps - Dependencies for testing
*/
export async function statusCommand(buildId, options = {}, globalOptions = {}) {
export async function statusCommand(
buildId,
options = {},
globalOptions = {},
deps = {}
) {
let {
loadConfig = defaultLoadConfig,
createApiClient = defaultCreateApiClient,
getBuild = defaultGetBuild,
getPreviewInfo = defaultGetPreviewInfo,
getApiUrl = defaultGetApiUrl,
output = defaultOutput,
exit = code => process.exit(code),
} = deps;

output.configure({
json: globalOptions.json,
verbose: globalOptions.verbose,
Expand All @@ -31,7 +51,7 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
output.error(
'API token required. Use --token or set VIZZLY_TOKEN environment variable'
);
process.exit(1);
exit(1);
}

// Get build details via functional API
Expand Down Expand Up @@ -232,11 +252,20 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {

// Exit with appropriate code based on build status
if (build.status === 'failed' || build.failed_jobs > 0) {
process.exit(1);
exit(1);
}
} catch (error) {
// Don't fail CI for Vizzly infrastructure issues (5xx errors)
let status = error.context?.status;
if (status >= 500) {
output.warn('Vizzly API unavailable - status check skipped.');
output.cleanup();
return { success: true, result: { skipped: true } };
}

output.error('Failed to get build status', error);
process.exit(1);
exit(1);
return { success: false, error };
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/commands/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ export async function uploadCommand(
output.cleanup();
return { success: true, result };
} catch (error) {
// Don't fail CI for Vizzly infrastructure issues (5xx errors)
let status = error.context?.status;
if (status >= 500) {
output.warn(
'Vizzly API unavailable - upload skipped. Your tests still ran.'
);
output.cleanup();
return { success: true, result: { skipped: true } };
}

// Mark build as failed if we have a buildId and config
if (buildId && config) {
try {
Expand Down
44 changes: 44 additions & 0 deletions tests/api/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,50 @@ describe('api/client', () => {
);
});

it('includes status code in error context for 5xx errors', async () => {
let client = createApiClient({
token: 'test-token',
baseUrl: 'https://api.test',
});

mockFetch.mock.mockImplementation(async () => ({
ok: false,
status: 503,
headers: new Map(),
text: async () => 'Service Unavailable',
}));

await assert.rejects(
() => client.request('/api/test'),
error => {
assert.strictEqual(error.context?.status, 503);
return true;
}
);
});

it('includes status code in error context for 4xx errors', async () => {
let client = createApiClient({
token: 'test-token',
baseUrl: 'https://api.test',
});

mockFetch.mock.mockImplementation(async () => ({
ok: false,
status: 404,
headers: new Map(),
text: async () => 'Not Found',
}));

await assert.rejects(
() => client.request('/api/test'),
error => {
assert.strictEqual(error.context?.status, 404);
return true;
}
);
});

it('throws VizzlyError for 403 forbidden', async () => {
let client = createApiClient({
token: 'test-token',
Expand Down
38 changes: 38 additions & 0 deletions tests/commands/finalize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function createMockOutput() {
info: msg => calls.push({ method: 'info', args: [msg] }),
debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }),
error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }),
warn: msg => calls.push({ method: 'warn', args: [msg] }),
success: msg => calls.push({ method: 'success', args: [msg] }),
data: d => calls.push({ method: 'data', args: [d] }),
startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }),
Expand Down Expand Up @@ -301,5 +302,42 @@ describe('commands/finalize', () => {
assert.strictEqual(capturedOptions.token, 'option-token');
assert.strictEqual(capturedOptions.verbose, true);
});

it('does not fail CI when API returns 5xx error', async () => {
let output = createMockOutput();
let exitCode = null;

let apiError = new Error('API request failed: 502 - Bad Gateway');
apiError.context = { status: 502 };

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(exitCode, null);
assert.ok(
output.calls.some(
c => c.method === 'warn' && c.args[0].includes('API unavailable')
)
);
});
});
});
96 changes: 96 additions & 0 deletions tests/commands/run.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,102 @@ describe('commands/run', () => {
);
});

it('does not fail CI when API returns 5xx error', async () => {
let output = createMockOutput();
let exitCode = null;

// Simulate a 5xx API error (e.g., Cloudflare 530)
let apiError = new Error('API request failed: 530');
apiError.context = { status: 530 };

let result = await runCommand(
'npm test',
{},
{},
{
loadConfig: async () => {
throw apiError;
},
output,
exit: code => {
exitCode = code;
},
processOn: () => {},
processRemoveListener: () => {},
}
);

// Should succeed (not fail CI)
assert.strictEqual(result.success, true);
assert.strictEqual(result.result.skipped, true);
assert.strictEqual(exitCode, null); // exit(1) should NOT be called

// Should show warning
assert.ok(
output.calls.some(
c => c.method === 'warn' && c.args[0].includes('API unavailable')
)
);
});

it('does not fail CI when API returns 500 error', async () => {
let output = createMockOutput();
let exitCode = null;

let apiError = new Error(
'API request failed: 500 - Internal Server Error'
);
apiError.context = { status: 500 };

let result = await runCommand(
'npm test',
{},
{},
{
loadConfig: async () => {
throw apiError;
},
output,
exit: code => {
exitCode = code;
},
processOn: () => {},
processRemoveListener: () => {},
}
);

assert.strictEqual(result.success, true);
assert.strictEqual(exitCode, null);
});

it('still fails CI for 4xx client errors', async () => {
let output = createMockOutput();
let exitCode = null;

let apiError = new Error('API request failed: 400 - Bad Request');
apiError.context = { status: 400 };

let result = await runCommand(
'npm test',
{},
{},
{
loadConfig: async () => {
throw apiError;
},
output,
exit: code => {
exitCode = code;
},
processOn: () => {},
processRemoveListener: () => {},
}
);

assert.strictEqual(result.success, false);
assert.strictEqual(exitCode, 1);
});

it('SIGINT handler triggers cleanup and exit', async () => {
let output = createMockOutput();
let handlers = {};
Expand Down
Loading