import { describeReason, installErrorLogging } from './diagnostics'; // jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это // сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому // собираем обычное Event и дописываем reason (слушатель читает только его). function emitRejection(reason: unknown): void { const event = new Event('unhandledrejection') as Event & { reason?: unknown }; event.reason = reason; window.dispatchEvent(event); } describe('installErrorLogging', () => { let errors: unknown[][]; let spy: jest.SpyInstance; beforeEach(() => { errors = []; spy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args); }); installErrorLogging(); }); afterEach(() => spy.mockRestore()); it('печатает сообщение и стек обычной ошибки', () => { emitRejection(new Error('Список недоступен')); const text = errors.map((a) => a.join(' ')).join('\n'); expect(text).toContain('Список недоступен'); expect(text).toContain('Стек'); }); it('не теряет причину, если это не Error, а объект (типичный отказ PnPjs)', () => { emitRejection({ status: 403, message: 'Access denied' }); const text = errors.map((a) => a.map((x) => JSON.stringify(x)).join(' ')).join('\n'); expect(text).toContain('403'); expect(text).toContain('Access denied'); }); it('не падает на причине с циклической ссылкой', () => { const cyclic: Record = { name: 'зацикленное' }; cyclic.self = cyclic; expect(() => emitRejection(cyclic)).not.toThrow(); expect(errors.length).toBeGreaterThan(0); }); it('строка-причина попадает в лог как есть', () => { emitRejection('просто текст'); expect(errors.map((a) => a.join(' ')).join('\n')).toContain('просто текст'); }); it('повторный вызов не вешает второй слушатель — ошибка не двоится', () => { installErrorLogging(); installErrorLogging(); emitRejection(new Error('однократно')); const count = errors.filter((a) => String(a[0]).indexOf('Необработанная ошибка') !== -1).length; expect(count).toBe(1); }); }); // describeReason — то, что пользователь реально видит в отчёте об ошибке (и в // угловом уведомлении ErrorOverlay), поэтому пустых строк он давать не должен // ни на одном виде причины. describe('describeReason', () => { it('промис без причины описывается словами, а не пустой строкой', () => { // Здесь и ломалось: JSON.stringify(undefined) возвращает undefined, и // сообщение схлопывалось в пустоту — по отчёту нельзя было понять даже // того, что причины не было вовсе. expect(describeReason(undefined)).toContain('без причины'); }); it('null тоже описывается', () => { expect(describeReason(null)).toContain('null'); }); it('ошибка — имя и сообщение', () => { expect(describeReason(new TypeError('нет доступа'))).toBe('TypeError: нет доступа'); }); it('ошибка с пустым сообщением не даёт пустую строку', () => { expect(describeReason(new Error(''))).toContain('пустое сообщение'); }); it('строка возвращается как есть', () => { expect(describeReason('сервер недоступен')).toBe('сервер недоступен'); }); it('обычный объект сериализуется', () => { expect(describeReason({ status: 404 })).toContain('404'); }); it('циклическая ссылка не роняет разбор — остаются ключи', () => { // Внутри ошибок PnPjs лежит Response со ссылками на себя: JSON.stringify на // таком объекте бросает, и раньше это происходило прямо внутри обработчика // события. const circular: Record = { status: 500 }; circular.self = circular; const text = describeReason(circular); expect(text).toContain('status'); expect(text).toContain('self'); }); });