348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
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:');
|
|
});
|
|
});
|