chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
CliError,
|
||||
createApiError,
|
||||
createConfigError,
|
||||
createCommandError,
|
||||
normalizeCliError,
|
||||
printCliError,
|
||||
printRechargeUrl,
|
||||
} from '../../bin/utils/cliError';
|
||||
|
||||
describe('cliError', () => {
|
||||
test('CliError constructor defaults details and suggestions', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new CliError({
|
||||
summary: 'Plain failure.',
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Plain failure.');
|
||||
expect(error.name).toBe('CliError');
|
||||
expect(error.stage).toBeUndefined();
|
||||
expect(error.details).toEqual([]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test('normalizes API business errors with request context', () => {
|
||||
const error = createApiError(
|
||||
'API request',
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
code: 40001,
|
||||
msg: 'Insufficient wallet balance',
|
||||
},
|
||||
},
|
||||
},
|
||||
['Request: POST /bind_dns'],
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(CliError);
|
||||
expect(error.message).toBe('Insufficient wallet balance');
|
||||
expect(error.details).toContain('Request: POST /bind_dns');
|
||||
expect(error.details).toContain('Business code: 40001');
|
||||
expect(error.details.join('\n')).toMatch(/Recharge URL:/);
|
||||
});
|
||||
|
||||
test('prefers nested API messages in documented order', () => {
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
message: 'Nested message',
|
||||
error: 'Nested error',
|
||||
},
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Nested message');
|
||||
|
||||
expect(
|
||||
createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
errors: [{ message: 'Array message' }],
|
||||
error: 'Top-level error',
|
||||
},
|
||||
},
|
||||
}).message,
|
||||
).toBe('Array message');
|
||||
});
|
||||
|
||||
test('normalizes HTTP errors without business code', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 503,
|
||||
data: { message: 'Service unavailable' },
|
||||
},
|
||||
message: 'Request failed with status code 503',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Service unavailable');
|
||||
expect(error.details).toContain('HTTP status: 503');
|
||||
expect(error.details.join('\n')).not.toMatch(/Reason:/);
|
||||
});
|
||||
|
||||
test('creates command errors with exit metadata', () => {
|
||||
const error = createCommandError(
|
||||
'frontend build',
|
||||
'npm run build:web',
|
||||
{
|
||||
status: 127,
|
||||
message: 'vite: command not found',
|
||||
},
|
||||
['Run npm install'],
|
||||
);
|
||||
|
||||
expect(error.details).toEqual([
|
||||
'Command: npm run build:web',
|
||||
'Exit code: 127',
|
||||
'Reason: vite: command not found',
|
||||
]);
|
||||
expect(error.suggestions).toEqual(['Run npm install']);
|
||||
});
|
||||
|
||||
test('creates command errors with code and signal metadata without suggestions', () => {
|
||||
const error = createCommandError('worker deploy', 'wrangler deploy', {
|
||||
code: 1,
|
||||
signal: 'SIGTERM',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('worker deploy failed.');
|
||||
expect(error.details).toEqual([
|
||||
'Command: wrangler deploy',
|
||||
'Exit code: 1',
|
||||
'Signal: SIGTERM',
|
||||
]);
|
||||
expect(error.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
test('normalizes unknown thrown values', () => {
|
||||
const error = normalizeCliError({ raw: true }, 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
expect(error.details).toEqual(['Raw error: {"raw":true}']);
|
||||
});
|
||||
|
||||
test('normalizes null and primitive thrown values', () => {
|
||||
expect(normalizeCliError(undefined, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError(null, 'Fallback failed.').details).toEqual([
|
||||
'Raw error: ',
|
||||
]);
|
||||
expect(normalizeCliError('plain failure', 'Fallback failed.').details).toEqual(
|
||||
['Raw error: plain failure'],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes circular thrown values through string fallback', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const error = normalizeCliError(circular, 'Fallback failed.');
|
||||
|
||||
expect(error.details).toEqual(['Raw error: [object Object]']);
|
||||
});
|
||||
|
||||
test('returns existing CliError instances unchanged', () => {
|
||||
const original = createConfigError('Missing config.', ['Create pinme.toml']);
|
||||
|
||||
expect(normalizeCliError(original, 'Fallback failed.')).toBe(original);
|
||||
});
|
||||
|
||||
test('normalizes regular Error instances with deduped suggestions', () => {
|
||||
const error = normalizeCliError(new Error('Boom'), 'Fallback failed.', [
|
||||
'Retry',
|
||||
'Retry',
|
||||
]);
|
||||
|
||||
expect(error.message).toBe('Boom');
|
||||
expect(error.suggestions).toEqual(['Retry']);
|
||||
});
|
||||
|
||||
test('normalizes Error instances with empty messages to the fallback summary', () => {
|
||||
const error = normalizeCliError(new Error(''), 'Fallback failed.');
|
||||
|
||||
expect(error.message).toBe('Fallback failed.');
|
||||
});
|
||||
|
||||
test('normalizes API-shaped thrown objects', () => {
|
||||
const error = normalizeCliError(
|
||||
{
|
||||
config: { method: 'get', url: '/wallet' },
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
error: 'Nested API failure',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback failed.',
|
||||
);
|
||||
|
||||
expect(error.message).toBe('Nested API failure');
|
||||
expect(error.stage).toBe('API request');
|
||||
});
|
||||
|
||||
test('includes non-Axios error code when response data is absent', () => {
|
||||
const error = createApiError('API request', {
|
||||
code: 'ECONNRESET',
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.details).toContain('Error code: ECONNRESET');
|
||||
expect(error.details).not.toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('uses string response data as summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: 'Bad gateway',
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Bad gateway');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
});
|
||||
|
||||
test('includes nested API detail when it differs from the summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: 'Top-level summary',
|
||||
data: {
|
||||
error: 'Nested detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Top-level summary');
|
||||
expect(error.details).toContain('HTTP status: 400');
|
||||
expect(error.details).toContain('Error detail: Nested detail');
|
||||
expect(error.details).not.toContain(
|
||||
'Reason: Request failed with status code 400',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the API stage when no response message exists', () => {
|
||||
const error = createApiError('wallet lookup', {});
|
||||
|
||||
expect(error.message).toBe('wallet lookup failed.');
|
||||
expect(error.stage).toBe('wallet lookup');
|
||||
expect(error.details).toEqual([]);
|
||||
});
|
||||
|
||||
test('includes raw reasons for non-generic API failures', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
status: 502,
|
||||
data: { error: 'Gateway wrapper failed' },
|
||||
},
|
||||
message: 'socket hang up',
|
||||
});
|
||||
|
||||
expect(error.message).toBe('Gateway wrapper failed');
|
||||
expect(error.details).toContain('HTTP status: 502');
|
||||
expect(error.details).toContain('Reason: socket hang up');
|
||||
});
|
||||
|
||||
test('includes stringified business messages when they differ from summary', () => {
|
||||
const error = createApiError('API request', {
|
||||
response: {
|
||||
data: {
|
||||
code: 409,
|
||||
msg: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.message).toBe('123');
|
||||
expect(error.details).toContain('Business code: 409');
|
||||
expect(error.details).toContain('Business message: 123');
|
||||
});
|
||||
|
||||
test('printRechargeUrl writes to stdout by default', () => {
|
||||
const messages: string[] = [];
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
printRechargeUrl('https://wallet.pinme.test');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('printCliError writes structured details and suggestions', () => {
|
||||
const originalError = createConfigError('Missing config.', [
|
||||
'Run pinme create app',
|
||||
]);
|
||||
originalError.details = ['Project root: /tmp/app'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(originalError, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Error: Missing config.');
|
||||
expect(messages.join('\n')).toContain('Stage: configuration');
|
||||
expect(messages.join('\n')).toContain('Project root: /tmp/app');
|
||||
expect(messages.join('\n')).toContain('Run pinme create app');
|
||||
});
|
||||
|
||||
test('printCliError prints recharge URLs through the highlighted branch', () => {
|
||||
const error = createConfigError('Needs funds.');
|
||||
error.details = ['Recharge URL: https://wallet.pinme.test'];
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(error, 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).toContain('Recharge URL:');
|
||||
expect(messages.join('\n')).toContain('https://wallet.pinme.test');
|
||||
});
|
||||
|
||||
test('printCliError skips next steps when suggestions are empty', () => {
|
||||
const messages: string[] = [];
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((value = '') => {
|
||||
messages.push(String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
printCliError(createConfigError('No suggestions.'), 'Fallback failed.');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(messages.join('\n')).not.toContain('Next steps:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user