From 9a73fff051916bc2fab1997f9fe7b9d5f6f7ba6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D1=80=D0=BE=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 12 Jul 2026 19:04:41 +0300 Subject: [PATCH] feat(*): add build error grouping and error suggestions --- .changeset/happy-carrots-cough.md | 8 + packages/arui-scripts/README.md | 1 + packages/arui-scripts/docs/error-grouping.md | 85 ++++ .../commands/start/print-compiler-output.ts | 14 - .../util/__tests__/error-category.test.ts | 172 +++++++ .../util/__tests__/error-formatter.test.ts | 458 ++++++++++++++++++ .../util/__tests__/error-location.test.ts | 212 ++++++++ .../util/__tests__/error-patterns.test.ts | 305 ++++++++++++ .../util/__tests__/print-build-error.test.ts | 84 ++++ .../src/commands/util/client-assets-sizes.ts | 2 +- .../src/commands/util/error-category.ts | 44 ++ .../src/commands/util/error-formatter.ts | 413 ++++++++++++++++ .../src/commands/util/error-location.ts | 85 ++++ .../src/commands/util/error-patterns.ts | 275 +++++++++++ .../src/commands/util/error-types.ts | 48 ++ .../src/commands/util/print-build-error.ts | 63 ++- .../commands/util/run-client-dev-server.ts | 60 ++- .../util/run-server-watch-compiler.ts | 31 +- .../__tests__/smart-errors-plugin.tests.ts | 172 +++++++ .../src/plugins/smart-errors-plugin.ts | 76 +++ 20 files changed, 2570 insertions(+), 38 deletions(-) create mode 100644 .changeset/happy-carrots-cough.md create mode 100644 packages/arui-scripts/docs/error-grouping.md delete mode 100644 packages/arui-scripts/src/commands/start/print-compiler-output.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/error-category.test.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/error-formatter.test.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/error-location.test.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/error-patterns.test.ts create mode 100644 packages/arui-scripts/src/commands/util/__tests__/print-build-error.test.ts create mode 100644 packages/arui-scripts/src/commands/util/error-category.ts create mode 100644 packages/arui-scripts/src/commands/util/error-formatter.ts create mode 100644 packages/arui-scripts/src/commands/util/error-location.ts create mode 100644 packages/arui-scripts/src/commands/util/error-patterns.ts create mode 100644 packages/arui-scripts/src/commands/util/error-types.ts create mode 100644 packages/arui-scripts/src/plugins/__tests__/smart-errors-plugin.tests.ts create mode 100644 packages/arui-scripts/src/plugins/smart-errors-plugin.ts diff --git a/.changeset/happy-carrots-cough.md b/.changeset/happy-carrots-cough.md new file mode 100644 index 00000000..6cf75cb4 --- /dev/null +++ b/.changeset/happy-carrots-cough.md @@ -0,0 +1,8 @@ +--- +'arui-scripts': minor +--- + +Улучшен вывод ошибок сборки для `start` и `build`. Ошибки группируются по категориям (TypeScript, +Module, CSS, Module Federation, Configuration и тд.), для известных типов ошибок выводятся подсказки по +исправлению (проверка пути или установка пакета, `tsc --noEmit` для ошибок типизации, увеличение лимита +памяти Node.js и тд) diff --git a/packages/arui-scripts/README.md b/packages/arui-scripts/README.md index 7f05659d..bcf5438f 100644 --- a/packages/arui-scripts/README.md +++ b/packages/arui-scripts/README.md @@ -57,3 +57,4 @@ npm install arui-scripts --save-dev - [Использование модулей](docs/modules.md) - [Client-only режим](./docs/client-only.md) - [Словарь для сжатия](./docs/compression-dictionary.md) +- [Вывод ошибок](./docs/error-grouping.md) diff --git a/packages/arui-scripts/docs/error-grouping.md b/packages/arui-scripts/docs/error-grouping.md new file mode 100644 index 00000000..7e9f649f --- /dev/null +++ b/packages/arui-scripts/docs/error-grouping.md @@ -0,0 +1,85 @@ +# Улучшенный вывод ошибок + +В режиме `start` / watch `arui-scripts` группирует ошибки сборки по категориям и выводит подсказки по исправлению. + +Группировка работает через `handleCompilationResult` в client/server watch-компиляторах. Команда `build` по-прежнему выводит одну ошибку через `printBuildError` с подсказками, но без группировки по категориям. + +## Группировка ошибок + +Ошибки группируются по типу для удобства навигации: + +| Категория | Описание | +|---|---| +| `Module Error` | Ошибки импорта (модуль не найден, экспорт не существует) | +| `TypeScript Error` | Ошибки типизации | +| `CSS Error` | Синтаксические ошибки стилей | +| `Syntax Error` | Синтаксические ошибки JS/TS | +| `Runtime Error` | Ошибки выполнения (React, undefined) | +| `Module Federation Error` | Проблемы с Module Federation | +| `Configuration Error` | Проблемы конфигурации (memory, target) | + +## Подсказки по исправлению + +Для каждой ошибки выводятся подсказки: + +``` +Client: Build failed + +Module Error: + • Module not found: Error: Can't resolve './Button' + at src/components/index.tsx:5 + Suggestions: + - Check that the file path is correct +``` + +Для пакетов из `node_modules` также печатается команда: + +``` +Suggestions: + - Check if the package is installed + Run: npm ls lodash + - Install the package + Run: npm install lodash +``` + +## Поддерживаемые ошибки + +### Модули не найдены + +Распознаются форматы Node (`Cannot find module`) и bundler (`Module not found` / `Can't resolve`). +Подсказки: проверка пути для relative imports или `npm ls` / `npm install` для пакетов. + +### TypeScript ошибки + +Команда для запуска `tsc --noEmit` для получения подробной информации. +`TS2307` и другие `TSxxxx` классифицируются как TypeScript, а не как module. + +### Heap out of memory + +Команда для увеличения памяти Node.js: + +```bash +NODE_OPTIONS=--max-old-space-size=4096 +``` + +### Module Federation + +- Проверка `remoteEntry.js` доступности +- Проверка что remote-приложение запущено +- Настройка shared dependencies + +### React element type + +Проверка корректности импорта компонентов. + +## Внутренний плагин + +`SmartErrorsPlugin` — внутренний helper для форматирования stats-ошибок. В дефолтный rspack-config он не подключается: вывод уже делается в watch-хуках (`handleCompilationResult` / `printBuildError`). Публичного export-пути `arui-scripts/plugins/smart-errors-plugin` нет. + +## Файлы модуля + +- `src/commands/util/error-formatter.ts` — форматирование ошибок +- `src/commands/util/error-patterns.ts` — паттерны ошибок и подсказки +- `src/commands/util/error-category.ts` — определение категории ошибки +- `src/commands/util/error-location.ts` — извлечение location из ошибки +- `src/plugins/smart-errors-plugin.ts` — внутренний helper / optional rspack plugin diff --git a/packages/arui-scripts/src/commands/start/print-compiler-output.ts b/packages/arui-scripts/src/commands/start/print-compiler-output.ts deleted file mode 100644 index 32d93587..00000000 --- a/packages/arui-scripts/src/commands/start/print-compiler-output.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type Stats } from '@rspack/core'; -import chalk from 'chalk'; - -import { statsOptions } from '../../configs/stats-options'; - -export function printCompilerOutput(compilerName: string, stats: Stats) { - const output = stats - .toString(statsOptions) - .split('\n') - .map((line: string) => `${chalk.cyan(`[${compilerName}]`)} ${line}`) - .join('\n'); - - console.log(output); -} diff --git a/packages/arui-scripts/src/commands/util/__tests__/error-category.test.ts b/packages/arui-scripts/src/commands/util/__tests__/error-category.test.ts new file mode 100644 index 00000000..6cc41f48 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/error-category.test.ts @@ -0,0 +1,172 @@ +import { getErrorCategory, getErrorTitle } from '../error-category'; + +describe('error-category', () => { + describe('getErrorCategory', () => { + it('returns module category for "Cannot find module" error', () => { + const error = { message: "Cannot find module 'lodash'" }; + + const result = getErrorCategory(error); + + expect(result).toBe('module'); + }); + + it('returns module category for "Cannot find file" error', () => { + const error = { message: 'Cannot find file ./utils.ts' }; + + const result = getErrorCategory(error); + + expect(result).toBe('module'); + }); + + it('returns module category for "Can\'t resolve" bundler error', () => { + const error = { + message: "Module not found: Error: Can't resolve './Button' in '/src/components'", + }; + + const result = getErrorCategory(error); + + expect(result).toBe('module'); + }); + + it('returns typescript category for TS error codes', () => { + const error = { message: 'TS2322: Type "string" is not assignable to type "number"' }; + + const result = getErrorCategory(error); + + expect(result).toBe('typescript'); + }); + + it('returns typescript category for TS2307 cannot find module', () => { + const error = { + message: + "TS2307: Cannot find module 'lodash' or its corresponding type declarations.", + }; + + const result = getErrorCategory(error); + + expect(result).toBe('typescript'); + }); + + it('returns runtime category for TypeError', () => { + const error = { message: 'TypeError: Cannot read properties of undefined' }; + + const result = getErrorCategory(error); + + expect(result).toBe('runtime'); + }); + + it('returns runtime category for ReferenceError', () => { + const error = { message: 'ReferenceError: foo is not defined' }; + + const result = getErrorCategory(error); + + expect(result).toBe('runtime'); + }); + + it('returns css category for CSS errors', () => { + const error = { message: 'Unknown word (css烘托)' }; + + const result = getErrorCategory(error); + + expect(result).toBe('css'); + }); + + it('returns syntax category for SyntaxError, not css', () => { + const error = { message: 'SyntaxError: Unexpected token' }; + + const result = getErrorCategory(error); + + expect(result).toBe('syntax'); + }); + + it('returns configuration category for heap out of memory', () => { + const error = { message: 'FATAL ERROR: JavaScript heap out of memory' }; + + const result = getErrorCategory(error); + + expect(result).toBe('configuration'); + }); + + it('does not treat short "config" substring as configuration', () => { + const error = { message: "Can't resolve './config'" }; + + const result = getErrorCategory(error); + + expect(result).toBe('module'); + }); + + it('returns unknown for unknown errors', () => { + const error = { message: 'Something went wrong' }; + + const result = getErrorCategory(error); + + expect(result).toBe('unknown'); + }); + }); + + describe('getErrorTitle', () => { + it('returns title by category, not by file extension', () => { + const error = { message: 'error', moduleName: '/path/to/file.ts' }; + + expect(getErrorTitle(error, 'module')).toBe('Module Error'); + expect(getErrorTitle(error, 'typescript')).toBe('TypeScript Error'); + expect( + getErrorTitle({ message: 'error', moduleName: '/path/to/styles.css' }, 'module'), + ).toBe('Module Error'); + }); + + it('returns Module Error for module category', () => { + const error = { message: 'error', moduleName: undefined }; + const category = 'module'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Module Error'); + }); + + it('returns Runtime Error for runtime category', () => { + const error = { message: 'error', moduleName: undefined }; + const category = 'runtime'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Runtime Error'); + }); + + it('returns Build Error for unknown category', () => { + const error = { message: 'error', moduleName: undefined }; + const category = 'unknown'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Build Error'); + }); + + it('returns Module Federation Error for module-federation category', () => { + const error = { message: 'error', moduleName: undefined }; + const category = 'module-federation'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Module Federation Error'); + }); + + it('returns Configuration Error for configuration category', () => { + const error = { message: 'error', moduleName: undefined }; + const category = 'configuration'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Configuration Error'); + }); + + it('returns Syntax Error for syntax category', () => { + const error = { message: 'error' }; + const category = 'syntax'; + + const result = getErrorTitle(error, category); + + expect(result).toBe('Syntax Error'); + }); + }); +}); diff --git a/packages/arui-scripts/src/commands/util/__tests__/error-formatter.test.ts b/packages/arui-scripts/src/commands/util/__tests__/error-formatter.test.ts new file mode 100644 index 00000000..b21f539f --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/error-formatter.test.ts @@ -0,0 +1,458 @@ +import { type Stats } from '@rspack/core'; + +import { + createErrorSummary, + formatError, + formatErrorForTerminal, + formatSummaryForTerminal, + groupErrorsByTitle, + handleCompilationResult, +} from '../error-formatter'; +import { type FormattedError } from '../error-types'; + +jest.mock('chalk', () => { + const mock = (text: string) => text; + const chalkMock = Object.assign(mock, { + red: (text: string) => text, + yellow: (text: string) => text, + cyan: (text: string) => text, + gray: (text: string) => text, + green: (text: string) => text, + bold: { red: (text: string) => text, yellow: (text: string) => text }, + }); + + return chalkMock; +}); + +describe('error-formatter', () => { + describe('formatError', () => { + it('formats "module not found" error', () => { + const error = new Error("Cannot find module './Button'"); + + const result = formatError(error); + + expect(result.category).toBe('module'); + expect(result.title).toBe('Module Error'); + expect(result.suggestions.length).toBeGreaterThan(0); + expect(result.suggestions[0]?.type).toBe('typo'); + }); + + it("formats bundler Can't resolve error", () => { + const error = new Error("Module not found: Error: Can't resolve 'lodash' in '/src'"); + + const result = formatError(error); + + expect(result.category).toBe('module'); + expect(result.title).toBe('Module Error'); + expect(result.suggestions.some((s) => s.command === 'npm install lodash')).toBe(true); + }); + + it('formats TS2307 as typescript, not module', () => { + const error = new Error( + "TS2307: Cannot find module 'lodash' or its corresponding type declarations.", + ); + + const result = formatError(error); + + expect(result.category).toBe('typescript'); + expect(result.title).toBe('TypeScript Error'); + expect(result.suggestions.some((s) => s.command === 'npx tsc --noEmit')).toBe(true); + }); + + it('uses moduleName from stats-like error object', () => { + const result = formatError({ + message: "Module not found: Error: Can't resolve './Button'", + moduleName: './src/components/index.tsx', + loc: '5:10-25', + }); + + expect(result.location?.file).toBe('./src/components/index.tsx'); + expect(result.location?.line).toBe(5); + expect(result.location?.column).toBe(10); + }); + + it('formats TypeScript error', () => { + const error = new Error('TS2322: Type "string" is not assignable to type "number"'); + + const result = formatError(error); + + expect(result.category).toBe('typescript'); + expect(result.title).toBe('TypeScript Error'); + expect(result.suggestions.some((s) => s.command === 'npx tsc --noEmit')).toBe(true); + }); + + it('formats CSS error', () => { + const error = new Error('Unknown word (css烘托)'); + + const result = formatError(error); + + expect(result.category).toBe('css'); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it('formats Module Federation error', () => { + const error = new Error('Remote module "./Button" not found in remote container'); + + const result = formatError(error); + + expect(result.category).toBe('module-federation'); + expect(result.suggestions.some((s) => s.message.includes('remoteEntry.js'))).toBe(true); + }); + + it('suggests NODE_OPTIONS for memory issues', () => { + const error = new Error('JavaScript heap out of memory'); + + const result = formatError(error); + + expect(result.category).toBe('configuration'); + expect(result.suggestions.some((s) => s.command?.includes('max-old-space-size'))).toBe( + true, + ); + }); + + it('handles unknown errors', () => { + const error = new Error('Some error'); + + const result = formatError(error); + + expect(result.category).toBe('unknown'); + expect(result.suggestions).toEqual([]); + }); + + it('extracts file path from stack trace', () => { + const error = new Error("Cannot find module './utils'"); + + error.stack = + "Error: Cannot find module './utils'\n at Function.Module (node:internal/modules/cjs/loader:1363:30)\n at src/components/Button/index.tsx:5:15 (src/components/Button/index.tsx:5:15)"; + + const result = formatError(error); + + expect(result.location).toBeDefined(); + expect(result.location?.file).toContain('Button'); + }); + + it('handles error without stack trace', () => { + const error = new Error('Test error'); + + const result = formatError(error); + + expect(result.category).toBe('unknown'); + }); + }); + + describe('groupErrorsByTitle', () => { + it('groups errors by title', () => { + const errors: FormattedError[] = [ + { + category: 'typescript', + severity: 'error', + title: 'TypeScript Error', + message: 'Error 1', + suggestions: [], + originalError: new Error(), + }, + { + category: 'typescript', + severity: 'error', + title: 'TypeScript Error', + message: 'Error 2', + suggestions: [], + originalError: new Error(), + }, + { + category: 'module', + severity: 'error', + title: 'Module Error', + message: 'Error 3', + suggestions: [], + originalError: new Error(), + }, + ]; + + const result = groupErrorsByTitle(errors); + + expect(Object.keys(result)).toHaveLength(2); + expect(result['TypeScript Error']).toHaveLength(2); + expect(result['Module Error']).toHaveLength(1); + }); + + it('returns empty object for empty array', () => { + const result = groupErrorsByTitle([]); + + expect(result).toEqual({}); + }); + }); + + describe('createErrorSummary', () => { + it('creates summary with error count', () => { + const errors: FormattedError[] = [ + { + category: 'typescript', + severity: 'error', + title: 'TypeScript Error', + message: 'Error 1', + suggestions: [], + originalError: new Error(), + }, + ]; + const warnings: FormattedError[] = [ + { + category: 'unknown', + severity: 'warning', + title: 'Warnings', + message: 'Warning 1', + suggestions: [], + originalError: new Error(), + }, + ]; + + const result = createErrorSummary(errors, warnings); + + expect(result.totalErrors).toBe(1); + expect(result.totalWarnings).toBe(1); + expect(result.errorsByCategory).toBeDefined(); + }); + }); + + describe('formatErrorForTerminal', () => { + it('formats error for terminal output', () => { + const error: FormattedError = { + category: 'module', + severity: 'error', + title: 'Module Error', + message: 'Cannot find module "./Button"', + location: { + file: 'src/components/Button/index.tsx', + line: 5, + column: 10, + }, + suggestions: [ + { + type: 'typo', + message: 'Check import path', + command: 'npm ls @/Button', + }, + ], + originalError: new Error(), + }; + + const result = formatErrorForTerminal(error, { maxSuggestions: 1, colorize: false }); + + expect(result).toContain('Module Error'); + expect(result).toContain('Cannot find module "./Button"'); + expect(result).toContain('src/components/Button/index.tsx:5'); + expect(result).toContain('Check import path'); + }); + + it('truncates long messages', () => { + const error: FormattedError = { + category: 'unknown', + severity: 'error', + title: 'Build Error', + message: 'x'.repeat(100), + suggestions: [], + originalError: new Error(), + }; + + const result = formatErrorForTerminal(error, { colorize: false }); + + expect(result).toContain('Build Error'); + }); + + it('prints full multiline message from original error', () => { + const originalError = new Error( + 'Module build failed:\n × Unexpected token\n ╭─[./src/App.tsx:5:1]', + ); + const error: FormattedError = { + category: 'syntax', + severity: 'error', + title: 'Syntax Error', + message: 'Module build failed:', + suggestions: [], + originalError, + }; + + const result = formatErrorForTerminal(error, { colorize: false }); + + expect(result).toContain('Unexpected token'); + expect(result).toContain('╭─[./src/App.tsx:5:1]'); + }); + + it('limits number of suggestions', () => { + const error: FormattedError = { + category: 'unknown', + severity: 'error', + title: 'Build Error', + message: 'Test', + suggestions: [ + { type: 'general', message: 'Suggestion 1' }, + { type: 'general', message: 'Suggestion 2' }, + { type: 'general', message: 'Suggestion 3' }, + ], + originalError: new Error(), + }; + + const result = formatErrorForTerminal(error, { maxSuggestions: 2, colorize: false }); + + expect(result).toContain('Suggestion 1'); + expect(result).toContain('Suggestion 2'); + expect(result).not.toContain('Suggestion 3'); + }); + }); + + describe('formatSummaryForTerminal', () => { + it('formats error summary', () => { + const errorsByCategory: Record = { + 'TypeScript Error': [ + { + category: 'typescript', + severity: 'error', + title: 'TypeScript Error', + message: 'Cannot find name "foo"', + location: { + file: 'src/index.tsx', + line: 10, + }, + suggestions: [], + originalError: new Error(), + }, + ], + }; + + const result = formatSummaryForTerminal(errorsByCategory, 1, 0); + + expect(result).toContain('Build failed'); + expect(result).toContain('1 error(s)'); + expect(result).toContain('TypeScript Error'); + expect(result).toContain('Cannot find name "foo"'); + }); + + it('shows warnings section', () => { + const errorsByCategory: Record = { + Warnings: [ + { + category: 'unknown', + severity: 'warning', + title: 'Warnings', + message: 'Deprecated API usage', + suggestions: [], + originalError: new Error(), + }, + ], + }; + + const result = formatSummaryForTerminal(errorsByCategory, 0, 1); + + expect(result).toContain('warning(s)'); + expect(result).toContain('Deprecated API usage'); + }); + + it('truncates long messages in summary', () => { + const errorsByCategory: Record = { + 'Build Error': [ + { + category: 'unknown', + severity: 'error', + title: 'Build Error', + message: 'x'.repeat(100), + suggestions: [], + originalError: new Error(), + }, + ], + }; + + const result = formatSummaryForTerminal(errorsByCategory, 1, 0); + + expect(result).toContain('...'); + }); + + it('shows "... and N more"', () => { + const errors: FormattedError[] = Array.from({ length: 10 }, (_, i) => ({ + category: 'unknown' as const, + severity: 'error' as const, + title: 'Error' as const, + message: `Error ${i + 1}`, + suggestions: [], + originalError: new Error(), + })); + + const errorsByCategory = { Errors: errors }; + + const result = formatSummaryForTerminal(errorsByCategory, 10, 0); + + expect(result).toContain('... and 5 more'); + }); + + it('returns empty string when no errors', () => { + const errorsByCategory: Record = {}; + + const result = formatSummaryForTerminal(errorsByCategory, 0, 0); + + expect(result).toBe(''); + }); + }); + + describe('handleCompilationResult', () => { + const makeStats = (errors: unknown[], warnings: unknown[]) => + ({ toJson: () => ({ errors, warnings }) }) as unknown as Stats; + + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn()); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + const getOutput = () => + consoleLogSpy.mock.calls.map((call: unknown[]) => call.join(' ')).join('\n'); + + it('filters deprecation and node warnings by message field', () => { + const stats = makeStats( + [], + [ + { message: 'DeprecationWarning: something old' }, + { message: 'node: assert module warning' }, + ], + ); + + handleCompilationResult(stats, 'Client'); + + expect(getOutput()).toContain('Client: Build successful'); + }); + + it('prints important warning text from message field', () => { + const stats = makeStats([], [{ message: 'Circular dependency detected' }]); + + handleCompilationResult(stats, 'Client'); + + const output = getOutput(); + + expect(output).toContain('Client: 1 warning(s)'); + expect(output).toContain('Circular dependency detected'); + }); + + it('prints grouped errors with location from stats fields', () => { + const stats = makeStats( + [ + { + message: "Module not found: Error: Can't resolve './Button'", + moduleName: './src/index.tsx', + loc: '5:10-20', + }, + ], + [], + ); + + handleCompilationResult(stats, 'Client'); + + const output = getOutput(); + + expect(output).toContain('Client: Build failed'); + expect(output).toContain('Module Error:'); + expect(output).toContain('./src/index.tsx'); + }); + }); +}); diff --git a/packages/arui-scripts/src/commands/util/__tests__/error-location.test.ts b/packages/arui-scripts/src/commands/util/__tests__/error-location.test.ts new file mode 100644 index 00000000..1d52b51f --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/error-location.test.ts @@ -0,0 +1,212 @@ +import { extractErrorMessage, extractLocation } from '../error-location'; + +describe('error-location', () => { + describe('extractLocation', () => { + it('extracts location from stack trace', () => { + const error = { + message: 'Error', + stack: 'Error at Object. (/path/to/file.js:10:5)', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/path/to/file.js', + line: 10, + column: 5, + }); + }); + + it('extracts location from rspack message', () => { + const error = { + message: '/path/to/file.ts:25:10 some error message', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/path/to/file.ts', + line: 25, + column: 10, + }); + }); + + it('uses error.loc if available', () => { + const error = { + message: 'Error', + loc: '10:15-20', + moduleName: '/path/to/Component.tsx', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/path/to/Component.tsx', + line: 10, + column: 15, + }); + }); + + it('parses multiline loc "startLine:startCol-endLine:endCol"', () => { + const error = { + message: 'Error', + loc: '5:10-6:2', + moduleName: './src/App.tsx', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: './src/App.tsx', + line: 5, + column: 10, + }); + }); + + it('parses line-range loc "5-10" without columns', () => { + const error = { + message: 'Error', + loc: '5-10', + moduleName: './src/App.tsx', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: './src/App.tsx', + line: 5, + column: undefined, + }); + }); + + it('prefers moduleName/loc over stack internals', () => { + const error = { + message: 'Error', + moduleName: './src/components/Button.tsx', + loc: '5:10-25', + stack: 'Error\n at Object. (/path/to/node_modules/loader.js:1:1)', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: './src/components/Button.tsx', + line: 5, + column: 10, + }); + }); + + it('prefers user file over node_modules in stack', () => { + const error = { + message: 'Error', + stack: `Error: fail + at Object. (/project/node_modules/loader/index.js:1:1) + at Module (/project/src/app.tsx:12:3)`, + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/project/src/app.tsx', + line: 12, + column: 3, + }); + }); + + it('uses error.file if moduleName is missing', () => { + const error = { + message: 'Error', + loc: '5:10-25', + file: '/path/to/image.png', + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/path/to/image.png', + line: 5, + column: 10, + }); + }); + + it('returns null if location not found', () => { + const error = { + message: 'Some generic error without location', + }; + + const result = extractLocation(error); + + expect(result).toBeNull(); + }); + + it('handles multiline stack trace', () => { + const error = { + message: 'Error', + stack: `Error: Something went wrong + at Function.module.exports (/path/to/utils.js:42:8) + at async Promise.all (index) + at Module._compile (node:internal/modules/cjs/loader:1150:10)`, + }; + + const result = extractLocation(error); + + expect(result).toEqual({ + file: '/path/to/utils.js', + line: 42, + column: 8, + }); + }); + }); + + describe('extractErrorMessage', () => { + it('extracts main message from error', () => { + const error = { + message: 'Cannot find module ./Button', + }; + + const result = extractErrorMessage(error); + + expect(result).toBe('Cannot find module ./Button'); + }); + + it('removes "Error:" prefix', () => { + const error = { + message: 'Error: Something went wrong', + }; + + const result = extractErrorMessage(error); + + expect(result).toBe('Something went wrong'); + }); + + it('removes "Module Error:" prefix', () => { + const error = { + message: 'Module Error: Cannot find module', + }; + + const result = extractErrorMessage(error); + + expect(result).toBe('Cannot find module'); + }); + + it('takes only first line from multiline message', () => { + const error = { + message: 'First line of error\nSecond line with details\nThird line', + }; + + const result = extractErrorMessage(error); + + expect(result).toBe('First line of error'); + }); + + it('trims leading and trailing spaces', () => { + const error = { + message: ' Error: Message with spaces ', + }; + + const result = extractErrorMessage(error); + + expect(result).toBe('Message with spaces'); + }); + }); +}); diff --git a/packages/arui-scripts/src/commands/util/__tests__/error-patterns.test.ts b/packages/arui-scripts/src/commands/util/__tests__/error-patterns.test.ts new file mode 100644 index 00000000..b9463caf --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/error-patterns.test.ts @@ -0,0 +1,305 @@ +import { ERROR_PATTERNS } from '../error-patterns'; + +describe('error-patterns', () => { + describe('ERROR_PATTERNS', () => { + it('contains all required patterns', () => { + expect(ERROR_PATTERNS.length).toBeGreaterThan(0); + }); + + it('each pattern has valid regex', () => { + ERROR_PATTERNS.forEach((pattern) => { + expect(pattern.pattern).toBeInstanceOf(RegExp); + }); + }); + + it('each pattern has category', () => { + ERROR_PATTERNS.forEach((pattern) => { + expect(pattern.category).toBeDefined(); + }); + }); + + it('each pattern has getSuggestions function', () => { + ERROR_PATTERNS.forEach((pattern) => { + expect(typeof pattern.getSuggestions).toBe('function'); + }); + }); + + it('contains pattern for module not found', () => { + const modulePattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Cannot find module 'lodash'"), + ); + + expect(modulePattern).toBeDefined(); + expect(modulePattern?.category).toBe('module'); + }); + + it("contains pattern for bundler Can't resolve errors", () => { + const modulePattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Module not found: Error: Can't resolve './Button' in '/src'"), + ); + + expect(modulePattern).toBeDefined(); + expect(modulePattern?.category).toBe('module'); + }); + + it('contains pattern for TypeScript errors', () => { + const tsPattern = ERROR_PATTERNS.find((p) => p.pattern.test('TS2307: error')); + + expect(tsPattern).toBeDefined(); + expect(tsPattern?.category).toBe('typescript'); + }); + + it('classifies TS2307 as typescript before module', () => { + const matched = ERROR_PATTERNS.find((p) => + p.pattern.test("TS2307: Cannot find module 'lodash'"), + ); + + expect(matched?.category).toBe('typescript'); + }); + + it('matches 5-digit TypeScript codes', () => { + const matched = ERROR_PATTERNS.find((p) => + p.pattern.test("TS18048: 'user' is possibly 'undefined'."), + ); + + expect(matched?.category).toBe('typescript'); + }); + + it('contains pattern for React element type', () => { + const reactPattern = ERROR_PATTERNS.find((p) => + p.pattern.test('Element type is invalid'), + ); + + expect(reactPattern).toBeDefined(); + expect(reactPattern?.category).toBe('runtime'); + }); + + it('contains pattern for Module Federation', () => { + const mfPattern = ERROR_PATTERNS.find((p) => p.pattern.test('Remote module not found')); + + expect(mfPattern).toBeDefined(); + expect(mfPattern?.category).toBe('module-federation'); + }); + + it('contains pattern for heap out of memory', () => { + const memoryPattern = ERROR_PATTERNS.find((p) => p.pattern.test('heap out of memory')); + + expect(memoryPattern).toBeDefined(); + expect(memoryPattern?.category).toBe('configuration'); + }); + + it('contains pattern for syntax errors', () => { + const syntaxPattern = ERROR_PATTERNS.find((p) => + p.pattern.test('parsing error: unexpected token'), + ); + + expect(syntaxPattern).toBeDefined(); + expect(syntaxPattern?.category).toBe('syntax'); + }); + + it('contains pattern for export not found', () => { + const exportPattern = ERROR_PATTERNS.find((p) => + p.pattern.test("'Button' is not exported from './Button'"), + ); + + expect(exportPattern).toBeDefined(); + expect(exportPattern?.category).toBe('module'); + }); + + it('contains pattern for remote container not available', () => { + const containerPattern = ERROR_PATTERNS.find((p) => + p.pattern.test('Remote container is not available'), + ); + + expect(containerPattern).toBeDefined(); + expect(containerPattern?.category).toBe('module-federation'); + }); + + it('contains pattern for unsupported target', () => { + const targetPattern = ERROR_PATTERNS.find((p) => + p.pattern.test('configuration for target "node14" not supported'), + ); + + expect(targetPattern).toBeDefined(); + expect(targetPattern?.category).toBe('configuration'); + }); + }); + + describe('getSuggestions for module not found', () => { + it('returns suggestions for npm package', () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Cannot find module 'lodash'"), + ); + + const suggestions = pattern?.getSuggestions({ + message: "Cannot find module 'lodash'", + }); + + expect(suggestions).toBeDefined(); + expect(Array.isArray(suggestions)).toBe(true); + expect(suggestions!.length).toBeGreaterThan(0); + expect(suggestions![0].type).toBe('install'); + expect(suggestions!.some((s) => s.command === 'npm install lodash')).toBe(true); + }); + + it('returns suggestions for relative path', () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Cannot find module './utils'"), + ); + + const suggestions = pattern?.getSuggestions({ + message: "Cannot find module './utils'", + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.length).toBeGreaterThan(0); + expect(suggestions![0].type).toBe('typo'); + expect(suggestions!.some((s) => s.command?.includes('npm'))).toBe(false); + }); + + it("returns suggestions for Can't resolve relative path", () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Module not found: Error: Can't resolve './Button'"), + ); + + const suggestions = pattern?.getSuggestions({ + message: "Module not found: Error: Can't resolve './Button' in '/src'", + }); + + expect(suggestions).toBeDefined(); + expect(suggestions![0].type).toBe('typo'); + }); + + it('returns suggestions for alias path', () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test("Cannot find module '@/components'"), + ); + + const suggestions = pattern?.getSuggestions({ + message: "Cannot find module '@/components'", + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.type === 'config')).toBe(true); + }); + }); + + describe('getSuggestions for TypeScript errors', () => { + it('returns tsc --noEmit command', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('TS2307: error')); + + const suggestions = pattern?.getSuggestions({ + message: 'TS2307: Cannot find module', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.command === 'npx tsc --noEmit')).toBe(true); + }); + }); + + describe('getSuggestions for heap out of memory', () => { + it('returns command to increase memory', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('heap out of memory')); + + const suggestions = pattern?.getSuggestions({ + message: 'FATAL ERROR: JavaScript heap out of memory', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.command?.includes('--max-old-space-size'))).toBe( + true, + ); + }); + }); + + describe('getSuggestions for module-federation', () => { + it('returns suggestions for remote module not found', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('Remote module not found')); + + const suggestions = pattern?.getSuggestions({ + message: 'Remote module not found', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.message.includes('remoteEntry'))).toBe(true); + }); + + it('returns suggestions for remote container not available', () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test('Remote container is not available'), + ); + + const suggestions = pattern?.getSuggestions({ + message: 'Remote container is not available', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.message.includes('running'))).toBe(true); + }); + }); + + describe('getSuggestions for React errors', () => { + it('returns suggestions for element type is invalid', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('Element type is invalid')); + + const suggestions = pattern?.getSuggestions({ + message: 'Element type is invalid', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.length).toBeGreaterThan(0); + }); + }); + + describe('getSuggestions for CSS errors', () => { + it('returns suggestions for unknown word', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('Unknown word')); + + const suggestions = pattern?.getSuggestions({ + message: 'Unknown word (css烘托)', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.some((s) => s.type === 'syntax')).toBe(true); + }); + + it('does not treat SyntaxError as CSS', () => { + const cssPattern = ERROR_PATTERNS.find((p) => p.pattern.test('Unknown word')); + const syntaxMessage = 'SyntaxError: Unexpected token }'; + + expect(cssPattern?.pattern.test(syntaxMessage)).toBe(false); + + const syntaxPattern = ERROR_PATTERNS.find((p) => p.pattern.test(syntaxMessage)); + + expect(syntaxPattern?.category).toBe('syntax'); + }); + }); + + describe('getSuggestions for syntax errors', () => { + it('returns suggestions for parsing error', () => { + const pattern = ERROR_PATTERNS.find((p) => p.pattern.test('parsing error')); + + const suggestions = pattern?.getSuggestions({ + message: 'Parsing error: unexpected token', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.length).toBeGreaterThan(0); + }); + }); + + describe('getSuggestions for configuration errors', () => { + it('returns suggestions for unsupported target', () => { + const pattern = ERROR_PATTERNS.find((p) => + p.pattern.test('configuration for target "node14" not supported'), + ); + + const suggestions = pattern?.getSuggestions({ + message: 'Configuration for target "node14" not supported', + }); + + expect(suggestions).toBeDefined(); + expect(suggestions!.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/arui-scripts/src/commands/util/__tests__/print-build-error.test.ts b/packages/arui-scripts/src/commands/util/__tests__/print-build-error.test.ts new file mode 100644 index 00000000..7aa9e9c4 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/__tests__/print-build-error.test.ts @@ -0,0 +1,84 @@ +import { printBuildError } from '../print-build-error'; + +jest.mock('chalk', () => { + const mock = (text: string) => text; + const chalkMock = Object.assign(mock, { + red: (text: string) => text, + yellow: (text: string) => text, + cyan: (text: string) => text, + gray: (text: string) => text, + green: (text: string) => text, + bold: { red: (text: string) => text }, + }); + return chalkMock; +}); + +describe('print-build-error', () => { + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn()); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('printBuildError', () => { + it('does nothing for null error', () => { + printBuildError(null); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('does nothing for undefined error', () => { + printBuildError(undefined); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('formats Terser error', () => { + const error = new Error('Minification error from Terser'); + + error.stack = + 'Error: Minification error\n at (file.js:1:2)[file.js:1,2,3][file.js:1,2]'; + + printBuildError(error); + + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('outputs regular errors', () => { + const error = new Error('Test error'); + + printBuildError(error); + + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('shows stack trace when showStack=true', () => { + const error = new Error('Test error'); + + error.stack = 'Error: Test error\n at test.ts:1:1'; + + printBuildError(error, { showStack: true }); + + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('does not show stack trace by default', () => { + const error = new Error('Test error'); + + error.stack = 'Error: Test error\n at test.ts:1:1'; + + printBuildError(error); + + const output = consoleLogSpy.mock.calls.map((c: unknown[]) => c.join(' ')).join(' '); + + expect(output).not.toContain('Stack trace:'); + }); + }); +}); diff --git a/packages/arui-scripts/src/commands/util/client-assets-sizes.ts b/packages/arui-scripts/src/commands/util/client-assets-sizes.ts index 46e27a0e..cb81b649 100644 --- a/packages/arui-scripts/src/commands/util/client-assets-sizes.ts +++ b/packages/arui-scripts/src/commands/util/client-assets-sizes.ts @@ -11,7 +11,7 @@ function removeFileNameHash(fileName: string) { const id = parts[0]; const isChunk = fileName.includes('.chunk.'); - const extension = parts.find((p) => ['js', 'css'].includes(p)); + const extension = parts.find((part) => ['js', 'css'].includes(part)); return `${id}${isChunk ? '.chunk' : ''}.${extension}`; } diff --git a/packages/arui-scripts/src/commands/util/error-category.ts b/packages/arui-scripts/src/commands/util/error-category.ts new file mode 100644 index 00000000..abd01055 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/error-category.ts @@ -0,0 +1,44 @@ +import { ERROR_PATTERNS, type WebpackErrorLike } from './error-patterns'; +import { type ErrorCategory } from './error-types'; + +const CATEGORY_BY_KEYWORD: Record = { + typeerror: 'runtime', + referenceerror: 'runtime', + configuration: 'configuration', + syntax: 'syntax', + css: 'css', + style: 'css', +}; + +const TITLE_BY_CATEGORY: Record = { + typescript: 'TypeScript Error', + module: 'Module Error', + css: 'CSS Error', + syntax: 'Syntax Error', + 'module-federation': 'Module Federation Error', + configuration: 'Configuration Error', + runtime: 'Runtime Error', + unknown: 'Build Error', +}; + +export function getErrorCategory(error: WebpackErrorLike): ErrorCategory { + for (const { pattern, category } of ERROR_PATTERNS) { + if (pattern.test(error.message)) { + return category; + } + } + + const message = error.message.toLowerCase(); + + for (const [keyword, category] of Object.entries(CATEGORY_BY_KEYWORD)) { + if (message.includes(keyword)) { + return category; + } + } + + return 'unknown'; +} + +export function getErrorTitle(_error: WebpackErrorLike, category: ErrorCategory): string { + return TITLE_BY_CATEGORY[category] ?? 'Build Error'; +} diff --git a/packages/arui-scripts/src/commands/util/error-formatter.ts b/packages/arui-scripts/src/commands/util/error-formatter.ts new file mode 100644 index 00000000..41d6f41f --- /dev/null +++ b/packages/arui-scripts/src/commands/util/error-formatter.ts @@ -0,0 +1,413 @@ +import { type Stats, type StatsError } from '@rspack/core'; +import chalk from 'chalk'; + +import { getErrorCategory, getErrorTitle } from './error-category'; +import { extractErrorMessage, extractLocation } from './error-location'; +import { DOCS_BASE_URL, ERROR_PATTERNS, type WebpackErrorLike } from './error-patterns'; +import { + type BuildErrorContext, + type ErrorLocation as ErrorLocationType, + type ErrorSuggestion, + type FormattedError, +} from './error-types'; + +export const DEFAULT_ERRORS_LIMIT = 5; +export const DEFAULT_SUGGESTIONS_LIMIT = 2; +export const DEFAULT_WARNINGS_LIMIT = 3; + +export function formatLocation(location: ErrorLocationType) { + if (!location.file) { + return ''; + } + + if (location.line) { + return location.column + ? `${location.file}:${location.line}:${location.column}` + : `${location.file}:${location.line}`; + } + + return location.file; +} + +export function isDeprecationWarning(text: string | undefined) { + if (!text) { + return false; + } + + return ( + text.includes('deprecated') || + text.includes('DeprecationWarning') || + text.includes('has been deprecated') + ); +} + +export function isNodeWarning(text: string | undefined) { + if (!text) { + return false; + } + + return ( + text.includes('node:') || + text.includes('Node.js') || + text.includes('Consider adding a "types"') + ); +} + +function printSuggestion(suggestion: ErrorSuggestion, indent = ' ') { + console.log(`${indent}- ${suggestion.message}`); + + if (suggestion.command) { + console.log(chalk.gray(`${indent} Run: ${suggestion.command}`)); + } +} + +function toWebpackErrorLike(error: Error | WebpackErrorLike | unknown): WebpackErrorLike { + if (error instanceof Error) { + const errorWithMeta = error as Error & Partial; + + return { + message: error.message, + stack: error.stack, + loc: errorWithMeta.loc, + moduleName: errorWithMeta.moduleName, + file: errorWithMeta.file, + }; + } + + if (error && typeof error === 'object' && 'message' in error) { + const errorLike = error as WebpackErrorLike; + + return { + message: String(errorLike.message ?? ''), + stack: errorLike.stack, + loc: errorLike.loc, + moduleName: errorLike.moduleName, + file: errorLike.file, + }; + } + + return { message: String(error) }; +} + +export function statsErrorToWebpackErrorLike(errorItem: StatsError): WebpackErrorLike { + const nestedError = (errorItem as StatsError & { error?: Error }).error; + + return { + message: errorItem.message || nestedError?.message || 'Unknown error', + stack: errorItem.stack || nestedError?.stack, + moduleName: errorItem.moduleName, + file: errorItem.file, + loc: errorItem.loc, + }; +} + +export function handleCompilationResult( + stats: Stats, + name: string, + options?: { + maxErrors?: number; + maxSuggestions?: number; + maxWarnings?: number; + filterWarnings?: (text: string | undefined) => boolean; + }, +) { + const { + maxErrors = DEFAULT_ERRORS_LIMIT, + maxSuggestions = DEFAULT_SUGGESTIONS_LIMIT, + maxWarnings = DEFAULT_WARNINGS_LIMIT, + filterWarnings = (text: string | undefined) => + isDeprecationWarning(text) || isNodeWarning(text), + } = options || {}; + + const statsJson = stats.toJson({ + errors: true, + warnings: true, + }); + + const errors = statsJson.errors || []; + const warnings = statsJson.warnings || []; + + if (errors.length > 0) { + console.log(chalk.red(`\n${name}: Build failed\n`)); + + const formattedErrors = errors.map((errorItem) => + formatError(statsErrorToWebpackErrorLike(errorItem)), + ); + const grouped = groupErrorsByTitle(formattedErrors); + + for (const [title, titleErrors] of Object.entries(grouped)) { + console.log(chalk.bold.red(`${title}:`)); + + const displayErrors = titleErrors.slice(0, maxErrors); + + displayErrors.forEach((errorItem) => { + console.log(` • ${errorItem.message}`); + + if (errorItem.location?.file) { + const locationStr = formatLocation(errorItem.location); + + console.log(chalk.cyan(` at ${locationStr}`)); + } + if (errorItem.suggestions.length > 0) { + console.log(chalk.gray(' Suggestions:')); + errorItem.suggestions.slice(0, maxSuggestions).forEach((suggestion) => { + printSuggestion(suggestion); + }); + } + }); + + if (titleErrors.length > maxErrors) { + const remaining = titleErrors.length - maxErrors; + + console.log(chalk.gray(` ... and ${remaining} more`)); + } + + console.log('\n'); + } + + console.log(chalk.gray(`Please report issues: ${DOCS_BASE_URL}\n`)); + + return; + } + + if (warnings.length > 0) { + // В stats.toJson() у предупреждений текст лежит в message (поля text у StatsError нет) + const importantWarnings = warnings.filter((warning) => !filterWarnings(warning.message)); + const displayWarnings = importantWarnings.slice(0, maxWarnings); + + if (displayWarnings.length > 0) { + console.log(chalk.yellow(`${name}: ${importantWarnings.length} warning(s)`)); + + displayWarnings.forEach((warning) => { + const text = + warning.message.length > 100 + ? `${warning.message.substring(0, 100)}...` + : warning.message; + + console.log(chalk.yellow(` ${text}`)); + }); + + if (importantWarnings.length > maxWarnings) { + const remaining = importantWarnings.length - maxWarnings; + + console.log(chalk.gray(` ... and ${remaining} more`)); + } + + console.log('\n'); + } else { + console.log(chalk.green(`${name}: Build successful`)); + } + + return; + } + + console.log(chalk.green(`${name}: Build successful`)); +} + +export function formatError( + error: Error | WebpackErrorLike | unknown, + context?: BuildErrorContext, +): FormattedError { + const errorLike = toWebpackErrorLike(error); + + const category = getErrorCategory(errorLike); + const title = getErrorTitle(errorLike, category); + const location = extractLocation(errorLike); + + const categorySuggestions: ErrorSuggestion[] = []; + + for (const { pattern, getSuggestions } of ERROR_PATTERNS) { + if (pattern.test(errorLike.message)) { + categorySuggestions.push(...getSuggestions(errorLike)); + break; + } + } + + const contextSuggestions: ErrorSuggestion[] = []; + + if (context?.moduleName) { + contextSuggestions.push({ + type: 'general', + message: `Module: ${context.moduleName}`, + }); + } + + const suggestions = [...categorySuggestions, ...contextSuggestions]; + + return { + category, + severity: 'error', + title, + message: extractErrorMessage(errorLike), + location: location + ? { + file: context?.modulePath || location.file || 'unknown', + line: location.line || context?.line, + column: location.column || context?.column, + } + : undefined, + suggestions, + originalError: error instanceof Error ? error : new Error(errorLike.message), + }; +} + +// Группировка по человекочитаемому заголовку (title), а не по служебному category +export function groupErrorsByTitle(errors: FormattedError[]): Record { + return errors.reduce((acc, error) => { + const key = error.title; + + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(error); + + return acc; + }, {} as Record); +} + +export function createErrorSummary( + errors: FormattedError[], + warnings: FormattedError[], +): { + totalErrors: number; + totalWarnings: number; + errorsByCategory: Record; +} { + return { + totalErrors: errors.length, + totalWarnings: warnings.length, + errorsByCategory: { + ...groupErrorsByTitle(errors), + Warnings: warnings, + }, + }; +} + +export function formatErrorForTerminal( + error: FormattedError, + options?: { maxSuggestions?: number; colorize?: boolean }, +) { + const { maxSuggestions = 3, colorize = true } = options || {}; + const lines: string[] = []; + const visibleSuggestions = error.suggestions.slice(0, maxSuggestions); + // В подробном одиночном выводе показываем полное сообщение (с деталями и код-фреймом), + // короткое error.message остаётся для сгруппированных списков + const fullMessage = String(error.originalError.message || error.message || ''); + const messageLines = fullMessage ? fullMessage.split('\n') : []; + + if (colorize) { + lines.push(chalk.red(`${error.title}`)); + + messageLines.forEach((messageLine) => { + lines.push(chalk.gray(` ${messageLine}`)); + }); + + if (error.location?.file) { + const locationStr = formatLocation(error.location); + + lines.push(chalk.cyan(` at ${locationStr}`)); + } + + if (visibleSuggestions.length > 0) { + lines.push(chalk.gray('\n Suggestions:')); + + visibleSuggestions.forEach((suggestion) => { + if (suggestion.command) { + lines.push(` • ${suggestion.message}`); + lines.push(chalk.gray(` Run: ${suggestion.command}`)); + } else { + lines.push(` • ${suggestion.message}`); + } + }); + } + } else { + lines.push(`${error.title}`); + + messageLines.forEach((messageLine) => { + lines.push(` ${messageLine}`); + }); + + if (error.location?.file) { + const locationStr = formatLocation(error.location); + + lines.push(` at ${locationStr}`); + } + + if (visibleSuggestions.length > 0) { + lines.push('\n Suggestions:'); + + visibleSuggestions.forEach((suggestion) => { + if (suggestion.command) { + lines.push(` • ${suggestion.message}`); + lines.push(` Run: ${suggestion.command}`); + } else { + lines.push(` • ${suggestion.message}`); + } + }); + } + } + + return lines.join('\n'); +} + +export function formatSummaryForTerminal( + errorsByCategory: Record, + totalErrors: number, + totalWarnings: number, +): string { + const lines: string[] = []; + + if (totalErrors > 0) { + lines.push(chalk.red(`\nBuild failed with ${totalErrors} error(s):\n`)); + } else if (totalWarnings > 0) { + lines.push(chalk.yellow(`\nBuild completed with ${totalWarnings} warning(s):\n`)); + } + + const nonEmptyCategories = Object.entries(errorsByCategory).filter( + ([, categoryErrors]) => categoryErrors.length > 0, + ); + + for (const [category, categoryErrors] of nonEmptyCategories) { + const isWarnings = category === 'Warnings'; + + if (isWarnings) { + lines.push(chalk.yellow(`${category}:`)); + } else { + lines.push(chalk.bold.red(`${category}:`)); + } + + const displayErrors = categoryErrors.slice(0, DEFAULT_ERRORS_LIMIT); + + displayErrors.forEach((error, index) => { + const prefix = isWarnings ? ' ' : ` ${index + 1}. `; + const message = + error.message.length > 80 ? `${error.message.substring(0, 80)}...` : error.message; + + if (isWarnings) { + lines.push(chalk.yellow(`${prefix}${message}`)); + } else { + lines.push(`${prefix}${message}`); + } + + if (error.location?.file) { + const locationStr = formatLocation(error.location); + + lines.push(chalk.gray(` at ${locationStr}`)); + } + }); + + if (categoryErrors.length > DEFAULT_ERRORS_LIMIT) { + lines.push( + chalk.gray(` ... and ${categoryErrors.length - DEFAULT_ERRORS_LIMIT} more`), + ); + } + + lines.push(''); + } + + if (totalErrors > 0) { + lines.push(chalk.gray(`\nDocs: ${DOCS_BASE_URL}`)); + } + + return lines.join('\n'); +} diff --git a/packages/arui-scripts/src/commands/util/error-location.ts b/packages/arui-scripts/src/commands/util/error-location.ts new file mode 100644 index 00000000..0c021dca --- /dev/null +++ b/packages/arui-scripts/src/commands/util/error-location.ts @@ -0,0 +1,85 @@ +import { type WebpackErrorLike } from './error-patterns'; +import { type ErrorLocation } from './error-types'; + +function isInternalStackFile(file: string) { + return file.includes('node_modules') || file.startsWith('node:') || file.includes('internal/'); +} + +// Разбирает webpack loc: "5:10-6:2" | "5:10-25" | "5:10" | "5-10" | "5". +// До "-" — стартовая позиция "line[:column]"; форма "5-10" — диапазон строк без колонок +function parseLoc(loc: string | undefined): { line?: number; column?: number } { + if (!loc) { + return {}; + } + + const [start] = loc.split('-'); + const [lineStr, columnStr] = start.split(':'); + const line = parseInt(lineStr, 10); + const column = parseInt(columnStr ?? '', 10); + + return { + line: Number.isNaN(line) ? undefined : line, + column: Number.isNaN(column) ? undefined : column, + }; +} + +export function extractLocation(error: WebpackErrorLike): ErrorLocation | null { + const stack = error.stack || ''; + const message = error.message || ''; + + // 1. Поля stats/bundler (moduleName/file + loc) + if (error.moduleName || error.file) { + const { line, column } = parseLoc(error.loc); + + return { + file: error.moduleName || error.file, + line, + column, + }; + } + + // 2. Путь из сообщения: "file.js:10:5" или "file.js:10" + const webpackLocationMatch = message.match(/^(.+?):(\d+)(?::(\d+))?/m); + + if (webpackLocationMatch) { + return { + file: webpackLocationMatch[1], + line: parseInt(webpackLocationMatch[2], 10), + column: webpackLocationMatch[3] ? parseInt(webpackLocationMatch[3], 10) : undefined, + }; + } + + // 3. Stack — предпочитаем user-файлы, не node_modules / node:internal + const stackMatches = [...stack.matchAll(/\(([^:]+):(\d+):(\d+)\)/g)]; + const preferredMatch = + stackMatches.find((match) => !isInternalStackFile(match[1])) || stackMatches[0]; + + if (preferredMatch) { + return { + file: preferredMatch[1], + line: parseInt(preferredMatch[2], 10), + column: parseInt(preferredMatch[3], 10), + }; + } + + // 4. loc без file — последний шанс + if (error.loc) { + const { line, column } = parseLoc(error.loc); + + return { + file: undefined, + line, + column, + }; + } + + return null; +} + +export function extractErrorMessage(error: WebpackErrorLike): string { + const lines = error.message.split('\n'); + const firstLine = lines[0].trim(); + const mainMessage = firstLine.replace(/^(Error:|Module Error:)\s*/i, ''); + + return mainMessage.trim(); +} diff --git a/packages/arui-scripts/src/commands/util/error-patterns.ts b/packages/arui-scripts/src/commands/util/error-patterns.ts new file mode 100644 index 00000000..0b39faa7 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/error-patterns.ts @@ -0,0 +1,275 @@ +import { type ErrorCategory, type ErrorSuggestion } from './error-types'; + +export const DOCS_BASE_URL = 'https://github.com/core-ds/arui-scripts/issues'; + +export interface ErrorPattern { + pattern: RegExp; + category: ErrorCategory; + getSuggestions: (error: WebpackErrorLike) => ErrorSuggestion[]; +} + +export interface WebpackErrorLike { + message: string; + stack?: string; + loc?: string; + moduleName?: string; + file?: string; +} + +function extractMissingModuleName(message: string): string { + const patterns = [ + /cannot find (?:module|file) ['"`]([^'"`\n]+)['"`]/i, + /cannot find (?:module|file) (\S+)/i, + /(?:can't|cannot) resolve ['"`]([^'"`]+)['"`]/i, + /(?:can't|cannot) resolve (\S+)/i, + /module not found:\s*(?:error:\s*)?(?:can't resolve\s+)?['"`]([^'"`]+)['"`]/i, + ]; + + for (const pattern of patterns) { + const match = pattern.exec(message); + + if (match?.[1]) { + return match[1]; + } + } + + return ''; +} + +function getSuggestionsForModuleNotFound(moduleName: string): ErrorSuggestion[] { + const isRelativePath = moduleName.startsWith('.'); + const suggestions: ErrorSuggestion[] = []; + + if (isRelativePath) { + suggestions.push({ + type: 'typo', + message: 'Check that the file path is correct', + }); + } else if (moduleName) { + suggestions.push({ + type: 'install', + message: 'Check if the package is installed', + command: `npm ls ${moduleName}`, + }); + suggestions.push({ + type: 'install', + message: 'Install the package', + command: `npm install ${moduleName}`, + }); + } else { + suggestions.push({ + type: 'typo', + message: 'Check that the module path or package name is correct', + }); + } + + if (moduleName.startsWith('@/')) { + suggestions.push({ + type: 'config', + message: 'Check tsconfig.json paths configuration', + }); + } + + suggestions.push({ + type: 'general', + message: `See module resolution issues: ${DOCS_BASE_URL}`, + }); + + return suggestions; +} + +export const ERROR_PATTERNS: ErrorPattern[] = [ + // TS-коды раньше module: иначе TS2307 уйдёт в module с советом npm install + { + // Коды TS бывают 4-5-значными (TS2307, TS18048) + pattern: /\bTS\d{4,5}\b:/, + category: 'typescript', + getSuggestions: () => [ + { + type: 'config', + message: 'Run type checking for details', + command: 'npx tsc --noEmit', + }, + { + type: 'general', + message: `See TypeScript issues: ${DOCS_BASE_URL}`, + }, + ], + }, + // MF раньше module: иначе "Remote module not found" матчит общий module-паттерн + { + pattern: /remote module.*not found|cannot find remote/i, + category: 'module-federation', + getSuggestions: () => [ + { + type: 'config', + message: 'Check remoteEntry.js is accessible', + }, + { + type: 'config', + message: 'Run build on remote application first', + }, + { + type: 'general', + message: `See Module Federation issues: ${DOCS_BASE_URL}`, + }, + ], + }, + { + pattern: /remote container.*not available/i, + category: 'module-federation', + getSuggestions: () => [ + { + type: 'config', + message: 'Check remote application is running', + }, + { + type: 'config', + message: 'Verify shared dependencies configuration', + }, + ], + }, + { + pattern: + /cannot find (?:module|file)|(? + getSuggestionsForModuleNotFound(extractMissingModuleName(error.message)), + }, + { + pattern: /'([^']+)' is not (?:exported|defined)/i, + category: 'module', + getSuggestions: (error) => { + const match = error.message.match(/'([^']+)' is not (?:exported|defined)/i); + const exportName = match?.[1] || ''; + const suggestions: ErrorSuggestion[] = []; + + if (exportName) { + suggestions.push({ + type: 'typo', + message: `Check for typos in import: "${exportName}"`, + }); + suggestions.push({ + type: 'typo', + message: 'Verify export exists in source module', + }); + } + + suggestions.push({ + type: 'general', + message: `See export issues: ${DOCS_BASE_URL}`, + }); + + return suggestions; + }, + }, + { + pattern: /element type is invalid/i, + category: 'runtime', + getSuggestions: () => [ + { + type: 'syntax', + message: 'Check component import is a valid React component', + }, + { + type: 'typo', + message: 'Ensure default export is used correctly', + }, + { + type: 'general', + message: `See React import issues: ${DOCS_BASE_URL}`, + }, + ], + }, + { + pattern: /Cannot read propert(?:y|ies) ['"]([^'"]+)['"]/, + category: 'runtime', + getSuggestions: (error) => [ + { + type: 'syntax', + message: `Check that property "${ + error.message.match(/['"]([^'"]+)['"]/)?.[1] + }" exists`, + }, + { + type: 'config', + message: 'Verify component receives correct props', + }, + ], + }, + { + pattern: /unknown word/i, + category: 'css', + getSuggestions: () => [ + { + type: 'syntax', + message: 'Check CSS syntax for errors', + }, + { + type: 'config', + message: 'Ensure proper CSS parser is configured', + }, + { + type: 'general', + message: `See CSS issues: ${DOCS_BASE_URL}`, + }, + ], + }, + { + pattern: /\bSyntaxError\b/i, + category: 'syntax', + getSuggestions: () => [ + { + type: 'syntax', + message: 'Check JavaScript/TypeScript syntax', + }, + { + type: 'syntax', + message: 'Ensure all brackets and parentheses are closed', + }, + ], + }, + { + pattern: /parsing error/i, + category: 'syntax', + getSuggestions: () => [ + { + type: 'syntax', + message: 'Check JavaScript/TypeScript syntax', + }, + { + type: 'syntax', + message: 'Ensure all brackets and parentheses are closed', + }, + ], + }, + { + pattern: /heap out of memory/i, + category: 'configuration', + getSuggestions: () => [ + { + type: 'config', + message: 'Increase Node.js memory limit', + command: 'NODE_OPTIONS=--max-old-space-size=4096', + }, + { + type: 'general', + message: `See memory issues: ${DOCS_BASE_URL}`, + }, + ], + }, + { + pattern: /configuration for target ["'][^"']+["'] not supported/i, + category: 'configuration', + getSuggestions: () => [ + { + type: 'config', + message: 'Check rspack/webpack target configuration', + }, + { + type: 'config', + message: 'Update to compatible Node.js version', + }, + ], + }, +]; diff --git a/packages/arui-scripts/src/commands/util/error-types.ts b/packages/arui-scripts/src/commands/util/error-types.ts new file mode 100644 index 00000000..abffbc62 --- /dev/null +++ b/packages/arui-scripts/src/commands/util/error-types.ts @@ -0,0 +1,48 @@ +import { type StatsError } from '@rspack/core'; + +export type ErrorSeverity = 'error' | 'warning'; + +export type ErrorCategory = + | 'typescript' + | 'module' + | 'css' + | 'syntax' + | 'module-federation' + | 'configuration' + | 'runtime' + | 'unknown'; + +export interface ErrorLocation { + file?: string; + line?: number; + column?: number; +} + +export interface BuildErrorContext { + moduleName?: string; + modulePath?: string; + line?: number; + column?: number; +} + +export interface ErrorSuggestion { + type: 'install' | 'typo' | 'config' | 'syntax' | 'general'; + message: string; + command?: string; + docsUrl?: string; +} + +export interface FormattedError { + category: ErrorCategory; + severity: ErrorSeverity; + title: string; + message: string; + location?: { + file: string; + line?: number; + column?: number; + }; + suggestions: ErrorSuggestion[]; + originalError: Error | StatsError; +} + diff --git a/packages/arui-scripts/src/commands/util/print-build-error.ts b/packages/arui-scripts/src/commands/util/print-build-error.ts index e3f8ea14..a6dccf50 100644 --- a/packages/arui-scripts/src/commands/util/print-build-error.ts +++ b/packages/arui-scripts/src/commands/util/print-build-error.ts @@ -1,15 +1,42 @@ import chalk from 'chalk'; -export function printBuildError(err: Error | null | undefined): void { - const message = err?.message; - const stack = err?.stack; +import { formatBuildError } from '../../plugins/smart-errors-plugin'; + +import { DOCS_BASE_URL } from './error-patterns'; + +// Выводит ошибку сборки с подсказками по исправлению +export function printBuildError( + err: Error | null | undefined, + options?: { + showStack?: boolean; + showSuggestions?: boolean; + }, +) { + const { showStack = false, showSuggestions = true } = options || {}; + + if (!err) { + return; + } + + const { message } = err; + const { stack } = err; if (stack && typeof message === 'string' && message.includes('from Terser')) { try { + // Из stack trace извлекаем путь к файлу, номер строки и колонки + // Формат: ...at (file.js:1:2)[file.js:1,2,3][file.js:1,2] const matched = /(.+)\[(.+):(.+),(.+)\]\[.+\]/.exec(stack); if (!matched) { - throw new Error('Using errors for control flow is bad.'); + console.log(chalk.red('Failed to minify the bundle.')); + + if (showStack) { + console.log(stack); + } + + console.log(`Please report this issue: ${DOCS_BASE_URL}`); + + return; } const problemPath = matched[2]; @@ -23,12 +50,28 @@ export function printBuildError(err: Error | null | undefined): void { `\t${problemPath}:${line}${columnFormatted}`, )}\n`, ); - } catch (ignored) { - console.log('Failed to minify the bundle.', err); + console.log(`Please report this issue: ${DOCS_BASE_URL}`); + } catch { + console.log(chalk.red('Bundle minification error.')); + if (showStack) { + console.log(stack); + } } - console.log('Read more here: https://cra.link/failed-to-minify'); - } else { - console.log(`${message || err}\n`); + console.log('\n'); + + return; } - console.log(); + + console.log( + formatBuildError(err, { + maxSuggestions: showSuggestions ? 3 : 0, + }), + ); + + if (showStack && stack) { + console.log(chalk.gray('\nStack trace:')); + console.log(stack); + } + + console.log('\n'); } diff --git a/packages/arui-scripts/src/commands/util/run-client-dev-server.ts b/packages/arui-scripts/src/commands/util/run-client-dev-server.ts index d6894443..293abcb3 100644 --- a/packages/arui-scripts/src/commands/util/run-client-dev-server.ts +++ b/packages/arui-scripts/src/commands/util/run-client-dev-server.ts @@ -1,19 +1,57 @@ +import path from 'path'; import { types } from 'util'; -import { type Configuration, rspack, type Stats } from '@rspack/core'; +import { type Compiler, type Configuration, type MultiCompiler, rspack } from '@rspack/core'; import { RspackDevServer } from '@rspack/dev-server'; +import chalk from 'chalk'; import { devServerConfig } from '../../configs/dev-server'; -import { printCompilerOutput } from '../start/print-compiler-output'; + +import { handleCompilationResult } from './error-formatter'; +import { printBuildError } from './print-build-error'; + +function getCompilers(compiler: Compiler | MultiCompiler): Compiler[] { + // Одиночное условие, чтобы TS сузил тип до Compiler в ветке else + if ('compilers' in compiler) { + return compiler.compilers; + } + + return [compiler]; +} export async function runClientDevServer(configuration: Configuration | Configuration[]) { + // rspack(array) → MultiCompiler; иначе остальные конфиги не стартуют в DevServer const clientCompiler = rspack(configuration); const clientDevServer = new RspackDevServer(devServerConfig, clientCompiler); + const configList = Array.isArray(configuration) ? configuration : [configuration]; + + getCompilers(clientCompiler).forEach((compiler, index) => { + const configName = configList[index]?.name || 'Client'; - clientCompiler.hooks.invalid.tap('client', () => console.log('Compiling client...')); - clientCompiler.hooks.done.tap('client', (stats) => - printCompilerOutput('Client', stats as Stats), - ); + // Изменение файла, начало перекомпиляции + compiler.hooks.invalid.tap(configName, (file) => { + const filePath = + typeof file === 'string' ? path.relative(process.cwd(), String(file)) : 'unknown'; + + console.log(chalk.gray(`${configName}: ${filePath} changed`)); + }); + + // Начало компиляции + compiler.hooks.compile.tap(configName, () => { + console.log(chalk.yellow(`${configName}: Compiling...`)); + }); + + // Успешное завершение компиляции + compiler.hooks.done.tap(configName, (stats) => { + handleCompilationResult(stats, configName); + }); + + // Ошибка компиляции + compiler.hooks.failed.tap(configName, (error) => { + console.error(chalk.red(`\n${configName} compilation failed:`)); + printBuildError(error, { showStack: true }); + }); + }); const DEFAULT_PORT = devServerConfig.port; const HOST = '0.0.0.0'; @@ -26,16 +64,20 @@ export async function runClientDevServer(configuration: Configuration | Configur }); if (!port) { - // We have not found a port. + console.error(chalk.red('Could not find an available port')); + return; } clientDevServer.startCallback(() => { - console.log(`Client dev server running at http://${HOST}:${port}...`); + console.log( + chalk.green(`\n${chalk.bold('Dev server running at')} http://${HOST}:${port}\n`), + ); }); } catch (err) { if (types.isNativeError(err)) { - console.log(err.message); + console.error(chalk.red('\nFailed to start dev server:')); + printBuildError(err, { showStack: true }); } process.exit(1); diff --git a/packages/arui-scripts/src/commands/util/run-server-watch-compiler.ts b/packages/arui-scripts/src/commands/util/run-server-watch-compiler.ts index 32eef7d6..e68b4606 100644 --- a/packages/arui-scripts/src/commands/util/run-server-watch-compiler.ts +++ b/packages/arui-scripts/src/commands/util/run-server-watch-compiler.ts @@ -1,15 +1,38 @@ import { type Configuration, rspack } from '@rspack/core'; +import chalk from 'chalk'; import { configs } from '../../configs/app-configs'; import { createWatchIgnoreRegex } from '../../configs/util/create-watch-ignore-regex'; -import { printCompilerOutput } from '../start/print-compiler-output'; + +import { handleCompilationResult } from './error-formatter'; +import { printBuildError } from './print-build-error'; export function runServerWatchCompiler(config: Configuration) { const serverCompiler = rspack(config); - serverCompiler.hooks.compile.tap('server', () => console.log('Compiling server...')); - serverCompiler.hooks.invalid.tap('server', () => console.log('Compiling server...')); - serverCompiler.hooks.done.tap('server', (stats) => printCompilerOutput('Server', stats)); + // Начало компиляции + serverCompiler.hooks.compile.tap('server', () => { + console.log(chalk.yellow('Server: Compiling...')); + }); + + // Изменение файла, начало перекомпиляции + serverCompiler.hooks.invalid.tap('server', (file) => { + const filePath = typeof file === 'string' ? file : 'unknown'; + + console.log(chalk.gray(`Server: ${filePath} changed`)); + }); + + // Успешное завершение компиляции + serverCompiler.hooks.done.tap('server', (stats) => { + handleCompilationResult(stats, 'Server'); + }); + + // Ошибка компиляции + serverCompiler.hooks.failed.tap('server', (error) => { + console.error(chalk.red('\nServer compilation failed:')); + + printBuildError(error, { showStack: true }); + }); serverCompiler.watch( { diff --git a/packages/arui-scripts/src/plugins/__tests__/smart-errors-plugin.tests.ts b/packages/arui-scripts/src/plugins/__tests__/smart-errors-plugin.tests.ts new file mode 100644 index 00000000..58f7efd9 --- /dev/null +++ b/packages/arui-scripts/src/plugins/__tests__/smart-errors-plugin.tests.ts @@ -0,0 +1,172 @@ +import { SmartErrorsPlugin } from '../smart-errors-plugin'; + +jest.mock('chalk', () => { + const mock = (text: string) => text; + const chalkMock = Object.assign(mock, { + red: (text: string) => text, + yellow: (text: string) => text, + cyan: (text: string) => text, + gray: (text: string) => text, + green: (text: string) => text, + bold: { red: (text: string) => text, yellow: (text: string) => text }, + }); + + return chalkMock; +}); + +describe('SmartErrorsPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('creates plugin with default options', () => { + const plugin = new SmartErrorsPlugin(); + + expect(plugin.name).toBe('SmartErrorsPlugin'); + }); + + it('accepts ignoreWarnings option', () => { + const plugin = new SmartErrorsPlugin({ + ignoreWarnings: true, + }); + + expect(plugin.name).toBe('SmartErrorsPlugin'); + }); + }); + + describe('apply', () => { + it('registers only done hook', () => { + const doneTap = jest.fn(); + const mockCompiler = { + hooks: { + compilation: { tap: jest.fn() }, + done: { tap: doneTap }, + }, + }; + const plugin = new SmartErrorsPlugin(); + + plugin.apply(mockCompiler as unknown as import('@rspack/core').Compiler); + + expect(doneTap).toHaveBeenCalledWith('SmartErrorsPlugin', expect.any(Function)); + }); + }); + + describe('error handling', () => { + it('does not output anything when no errors', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn()); + const doneTap = jest.fn(); + const mockCompiler = { + hooks: { + compilation: { tap: jest.fn() }, + done: { tap: doneTap }, + }, + }; + + const plugin = new SmartErrorsPlugin(); + + plugin.apply(mockCompiler as unknown as import('@rspack/core').Compiler); + + const doneHook = doneTap.mock.calls[0]?.[1] as (stats: { + toJson: () => { errors: []; warnings: [] }; + }) => void; + + if (doneHook) { + doneHook({ + toJson: () => ({ errors: [], warnings: [] }), + }); + } + + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('outputs errors when present', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn()); + const doneTap = jest.fn(); + const mockCompiler = { + hooks: { + compilation: { tap: jest.fn() }, + done: { tap: doneTap }, + }, + }; + + const plugin = new SmartErrorsPlugin(); + + plugin.apply(mockCompiler as unknown as import('@rspack/core').Compiler); + + const doneHook = doneTap.mock.calls[0]?.[1] as (stats: { + toJson: () => { errors: [{ error: Error }]; warnings: [] }; + }) => void; + + if (doneHook) { + doneHook({ + toJson: () => ({ + errors: [{ error: new Error('Test error') }], + warnings: [], + }), + }); + } + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('ignores warnings when ignoreWarnings=true', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn()); + const doneTap = jest.fn(); + const mockCompiler = { + hooks: { + compilation: { tap: jest.fn() }, + done: { tap: doneTap }, + }, + }; + + const plugin = new SmartErrorsPlugin({ ignoreWarnings: true }); + + plugin.apply(mockCompiler as unknown as import('@rspack/core').Compiler); + + const doneHook = doneTap.mock.calls[0]?.[1] as (stats: { + toJson: () => { errors: []; warnings: [{ warning: Error }] }; + }) => void; + + if (doneHook) { + doneHook({ + toJson: () => ({ + errors: [], + warnings: [{ warning: new Error('Warning') }], + }), + }); + } + + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); +}); + +describe('createSmartErrorsPlugin', () => { + it('creates SmartErrorsPlugin instance', () => { + const { createSmartErrorsPlugin } = require('../smart-errors-plugin'); + const plugin = createSmartErrorsPlugin({ + ignoreWarnings: true, + }); + + expect(plugin).toBeInstanceOf(SmartErrorsPlugin); + }); +}); + +describe('formatBuildError', () => { + it('formats simple error', () => { + const { formatBuildError } = require('../smart-errors-plugin'); + const error = new Error("Cannot find module './Button.tsx'"); + + const result = formatBuildError(error); + + expect(result).toContain('Module Error'); + expect(result).toContain('Cannot find module'); + }); +}); diff --git a/packages/arui-scripts/src/plugins/smart-errors-plugin.ts b/packages/arui-scripts/src/plugins/smart-errors-plugin.ts new file mode 100644 index 00000000..8e8a6974 --- /dev/null +++ b/packages/arui-scripts/src/plugins/smart-errors-plugin.ts @@ -0,0 +1,76 @@ +import { type Compiler, type StatsError } from '@rspack/core'; + +import { + createErrorSummary, + formatError, + formatErrorForTerminal, + formatSummaryForTerminal, + statsErrorToWebpackErrorLike, +} from '../commands/util/error-formatter'; + +interface StatsJson { + errors?: StatsError[]; + warnings?: StatsError[]; +} + +export interface SmartErrorsPluginOptions { + ignoreWarnings?: boolean; +} + +export class SmartErrorsPlugin { + name = 'SmartErrorsPlugin'; + + private options: SmartErrorsPluginOptions; + + constructor(options: SmartErrorsPluginOptions = {}) { + this.options = options; + } + + apply(compiler: Compiler) { + compiler.hooks.done.tap(this.name, (stats) => { + const statsJson = stats.toJson({ errors: true, warnings: true }) as StatsJson; + const errors = statsJson.errors || []; + const warnings = statsJson.warnings || []; + + if (errors.length === 0 && warnings.length === 0) { + return; + } + + const formattedErrors = errors.map((errorItem) => + formatError(statsErrorToWebpackErrorLike(errorItem)), + ); + const formattedWarnings = this.options.ignoreWarnings + ? [] + : warnings.map((warningItem) => + formatError(statsErrorToWebpackErrorLike(warningItem)), + ); + + const summary = createErrorSummary(formattedErrors, formattedWarnings); + + if ( + summary.totalErrors > 0 || + (summary.totalWarnings > 0 && !this.options.ignoreWarnings) + ) { + const summaryOutput = formatSummaryForTerminal( + summary.errorsByCategory, + summary.totalErrors, + summary.totalWarnings, + ); + + console.log(`\n${summaryOutput}`); + } + }); + } +} + +export function createSmartErrorsPlugin(options?: SmartErrorsPluginOptions): SmartErrorsPlugin { + return new SmartErrorsPlugin(options); +} + +export function formatBuildError(error: Error, options?: { maxSuggestions?: number }) { + const formatted = formatError(error); + + return formatErrorForTerminal(formatted, { + maxSuggestions: options?.maxSuggestions ?? 3, + }); +}