diff --git a/actions/build.action.ts b/actions/build.action.ts index 136a9b049..cb08f822c 100644 --- a/actions/build.action.ts +++ b/actions/build.action.ts @@ -1,6 +1,5 @@ import { red } from 'ansis'; import { join } from 'path'; -import * as ts from 'typescript'; import { Input } from '../commands'; import { AssetsManager } from '../lib/compiler/assets-manager'; import { deleteOutDirIfEnabled } from '../lib/compiler/helpers/delete-out-dir'; @@ -25,6 +24,7 @@ import { ERROR_PREFIX, INFO_PREFIX } from '../lib/ui'; import { isModuleAvailable } from '../lib/utils/is-module-available'; import { AbstractAction } from './abstract.action'; import webpack = require('webpack'); +import { TsConfigProviderOutput } from '../lib/compiler/helpers/tsconfig-provider'; export class BuildAction extends AbstractAction { protected readonly pluginsLoader = new PluginsLoader(); @@ -185,7 +185,7 @@ export class BuildAction extends AbstractAction { pathToTsconfig: string, watchMode: boolean, options: Input[], - tsOptions: ts.CompilerOptions, + tsOptions: TsConfigProviderOutput['options'], onSuccess: (() => void) | undefined, ) { const { SwcCompiler } = await import('../lib/compiler/swc/swc-compiler'); diff --git a/lib/compiler/defaults/swc-defaults.ts b/lib/compiler/defaults/swc-defaults.ts index a060881c3..11054d890 100644 --- a/lib/compiler/defaults/swc-defaults.ts +++ b/lib/compiler/defaults/swc-defaults.ts @@ -1,8 +1,8 @@ -import * as ts from 'typescript'; import { Configuration } from '../../configuration'; +import { TsConfigProviderOutput } from '../helpers/tsconfig-provider'; export const swcDefaultsFactory = ( - tsOptions?: ts.CompilerOptions, + tsOptions?: TsConfigProviderOutput['options'], configuration?: Configuration, ) => { const builderOptions = @@ -43,6 +43,7 @@ export const swcDefaultsFactory = ( extensions: ['.js', '.ts'], copyFiles: false, includeDotfiles: false, + ignore: tsOptions?.exclude?.length ? tsOptions.exclude : undefined, quiet: false, watch: false, stripLeadingPaths: true, diff --git a/lib/compiler/helpers/tsconfig-provider.ts b/lib/compiler/helpers/tsconfig-provider.ts index 49dbf0bf7..b05b9e529 100644 --- a/lib/compiler/helpers/tsconfig-provider.ts +++ b/lib/compiler/helpers/tsconfig-provider.ts @@ -4,10 +4,29 @@ import * as ts from 'typescript'; import { CLI_ERRORS } from '../../ui'; import { TypeScriptBinaryLoader } from '../typescript-loader'; +export type TsConfigProviderOutput = Pick< + ts.ParsedCommandLine, + 'fileNames' | 'projectReferences' +> & { + options: ts.ParsedCommandLine['options'] & { exclude: string[] }; +}; + export class TsConfigProvider { constructor(private readonly typescriptLoader: TypeScriptBinaryLoader) {} - public getByConfigFilename(configFilename: string) { + private parseExclude(exclude: unknown): string[] { + const passesTypeValidation = + Array.isArray(exclude) && + exclude.every((item) => typeof item === 'string'); + + if (!passesTypeValidation) { + return []; + } + + return exclude; + } + + public getByConfigFilename(configFilename: string): TsConfigProviderOutput { const configPath = join(process.cwd(), configFilename); if (!existsSync(configPath)) { throw new Error(CLI_ERRORS.MISSING_TYPESCRIPT(configFilename)); @@ -18,7 +37,18 @@ export class TsConfigProvider { undefined!, tsBinary.sys as unknown as ts.ParseConfigFileHost, ); - const { options, fileNames, projectReferences } = parsedCmd!; + const { + options: rawOptions, + fileNames, + projectReferences, + raw, + } = parsedCmd!; + + const options = { + ...rawOptions, + exclude: this.parseExclude(raw?.exclude), + }; + return { options, fileNames, projectReferences }; } } diff --git a/lib/compiler/swc/swc-compiler.ts b/lib/compiler/swc/swc-compiler.ts index ce546dfbb..93b818206 100644 --- a/lib/compiler/swc/swc-compiler.ts +++ b/lib/compiler/swc/swc-compiler.ts @@ -5,7 +5,6 @@ import { readFileSync } from 'fs'; import { stat } from 'fs/promises'; import * as path from 'path'; import { isAbsolute, join } from 'path'; -import * as ts from 'typescript'; import { Configuration } from '../../configuration'; import { ERROR_PREFIX } from '../../ui'; import { treeKillSync } from '../../utils/tree-kill'; @@ -21,12 +20,13 @@ import { SWC_LOG_PREFIX, } from './constants'; import { TypeCheckerHost } from './type-checker-host'; +import { TsConfigProviderOutput } from '../helpers/tsconfig-provider'; export type SwcCompilerExtras = { watch: boolean; typeCheck: boolean; assetsManager: AssetsManager; - tsOptions: ts.CompilerOptions; + tsOptions: TsConfigProviderOutput['options']; }; export class SwcCompiler extends BaseCompiler { diff --git a/test/lib/compiler/defaults/swc-defaults.spec.ts b/test/lib/compiler/defaults/swc-defaults.spec.ts new file mode 100644 index 000000000..d9e570d3f --- /dev/null +++ b/test/lib/compiler/defaults/swc-defaults.spec.ts @@ -0,0 +1,130 @@ +import { swcDefaultsFactory } from '../../../../lib/compiler/defaults/swc-defaults'; + +describe('swcDefaultsFactory', () => { + it('should return default configuration when no options are provided', () => { + const result = swcDefaultsFactory(); + + expect(result.swcOptions).toEqual({ + sourceMaps: undefined, + module: { + type: 'commonjs', + }, + jsc: { + target: 'es2021', + parser: { + syntax: 'typescript', + decorators: true, + dynamicImport: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + useDefineForClassFields: false, + }, + keepClassNames: true, + baseUrl: undefined, + paths: undefined, + }, + minify: false, + swcrc: true, + }); + + expect(result.cliOptions).toEqual({ + outDir: 'dist', + filenames: ['src'], + sync: false, + extensions: ['.js', '.ts'], + copyFiles: false, + includeDotfiles: false, + ignore: undefined, + quiet: false, + watch: false, + stripLeadingPaths: true, + }); + }); + + describe('swcOptions', () => { + it('should set sourceMaps to true if sourceMap is true in tsOptions', () => { + const result = swcDefaultsFactory({ sourceMap: true, exclude: [] }); + expect(result.swcOptions.sourceMaps).toBe(true); + }); + + it('should set sourceMaps to "inline" if inlineSourceMap is true in tsOptions', () => { + const result = swcDefaultsFactory({ inlineSourceMap: true, exclude: [] }); + expect(result.swcOptions.sourceMaps).toBe('inline'); + }); + + it('should set baseUrl and paths from tsOptions', () => { + const tsOptions = { + baseUrl: './', + paths: { + '@app/*': ['src/*'], + }, + exclude: [], + }; + const result = swcDefaultsFactory(tsOptions); + expect(result.swcOptions.jsc.baseUrl).toBe('./'); + expect(result.swcOptions.jsc.paths).toEqual({ + '@app/*': ['src/*'], + }); + }); + }); + + describe('cliOptions', () => { + it('should use sourceRoot from configuration for filenames', () => { + const configuration = { sourceRoot: 'custom-src' }; + const result = swcDefaultsFactory(undefined, configuration); + expect(result.cliOptions.filenames).toEqual(['custom-src']); + }); + + it('should use outDir from tsOptions and convert path', () => { + const tsOptions = { outDir: 'build\\dist', exclude: [] }; + const result = swcDefaultsFactory(tsOptions); + expect(result.cliOptions.outDir).toBe('build/dist'); + }); + + it('should handle Windows specific path prefixes in outDir', () => { + const tsOptions = { outDir: '\\\\?\\C:\\dist', exclude: [] }; + const result = swcDefaultsFactory(tsOptions); + expect(result.cliOptions.outDir).toBe('C:/dist'); + }); + + it('should set ignore if exclude is provided in tsOptions', () => { + const tsOptions = { exclude: ['test/**/*.ts'] }; + const result = swcDefaultsFactory(tsOptions); + expect(result.cliOptions.ignore).toEqual(['test/**/*.ts']); + }); + + it('should merge builder options from configuration', () => { + const configuration = { + compilerOptions: { + builder: { + type: 'swc' as const, + options: { + watch: true, + sync: true, + copyFiles: true, + }, + }, + }, + }; + const result = swcDefaultsFactory(undefined, configuration); + expect(result.cliOptions.watch).toBe(true); + expect(result.cliOptions.sync).toBe(true); + expect(result.cliOptions.copyFiles).toBe(true); + }); + + it('should not merge builder options if builder is a string', () => { + const configuration = { + compilerOptions: { + builder: 'swc', + }, + }; + + const result = swcDefaultsFactory(undefined, configuration as any); + expect(result.cliOptions.watch).toBe(false); + expect(result.cliOptions.sync).toBe(false); + expect(result.cliOptions.copyFiles).toBe(false); + }); + }); +}); diff --git a/test/lib/compiler/helpers/tsconfig-provider.spec.ts b/test/lib/compiler/helpers/tsconfig-provider.spec.ts new file mode 100644 index 000000000..50558de77 --- /dev/null +++ b/test/lib/compiler/helpers/tsconfig-provider.spec.ts @@ -0,0 +1,139 @@ +import { TsConfigProvider } from '../../../../lib/compiler/helpers/tsconfig-provider'; +import { TypeScriptBinaryLoader } from '../../../../lib/compiler/typescript-loader'; +import { join } from 'path'; +import * as fs from 'fs'; + +jest.mock('fs'); + +describe('TsConfigProvider', () => { + let provider: TsConfigProvider; + let typescriptLoaderMock: {load: jest.SpyInstance}; + let tsBinaryMock: {getParsedCommandLineOfConfigFile: jest.Mock; sys: object}; + + beforeEach(() => { + const typescriptLoader = new TypeScriptBinaryLoader(); + provider = new TsConfigProvider(typescriptLoader); + tsBinaryMock = { + getParsedCommandLineOfConfigFile: jest.fn(), + sys: {}, + }; + + typescriptLoaderMock = { + load: jest + .spyOn(typescriptLoader, 'load') + .mockReturnValue(tsBinaryMock as any) + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + describe('getByConfigFilename', () => { + it('should throw an error if config file does not exist', () => { + const configFilename = 'tsconfig.json'; + (fs.existsSync as jest.Mock).mockReturnValue(false); + + expect(() => provider.getByConfigFilename(configFilename)).toThrow( + 'Could not find TypeScript configuration file', + ); + }); + + it('should return parsed command line if config file exists', () => { + const configFilename = 'tsconfig.json'; + const configPath = join(process.cwd(), configFilename); + const mockParsedCmd = { + options: { outDir: 'dist' }, + fileNames: ['src/main.ts'], + projectReferences: [], + raw: { exclude: ['node_modules'] }, + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + tsBinaryMock.getParsedCommandLineOfConfigFile.mockReturnValue(mockParsedCmd); + + const result = provider.getByConfigFilename(configFilename); + + expect(fs.existsSync).toHaveBeenCalledWith(configPath); + expect(typescriptLoaderMock.load).toHaveBeenCalled(); + expect(tsBinaryMock.getParsedCommandLineOfConfigFile).toHaveBeenCalledWith( + configPath, + undefined, + tsBinaryMock.sys, + ); + expect(result).toEqual({ + options: { + outDir: 'dist', + exclude: ['node_modules'], + }, + fileNames: ['src/main.ts'], + projectReferences: [], + }); + }); + + it('should handle missing exclude in raw config', () => { + const configFilename = 'tsconfig.json'; + const mockParsedCmd = { + options: { outDir: 'dist' }, + fileNames: ['src/main.ts'], + projectReferences: [], + raw: {}, + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + tsBinaryMock.getParsedCommandLineOfConfigFile.mockReturnValue(mockParsedCmd); + + const result = provider.getByConfigFilename(configFilename); + + expect(result.options.exclude).toEqual([]); + }); + + it('should handle wrongly typed exclude in raw config', () => { + const configFilename = 'tsconfig.json'; + const mockParsedCmd = { + options: { outDir: 'dist' }, + fileNames: ['src/main.ts'], + projectReferences: [], + raw: { exclude: 'not-an-array' as unknown as string[] }, + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + tsBinaryMock.getParsedCommandLineOfConfigFile.mockReturnValue(mockParsedCmd); + + const result1 = provider.getByConfigFilename(configFilename); + + expect(result1.options.exclude).toEqual([]); + + mockParsedCmd.raw.exclude = [0, false] as unknown as string[]; + tsBinaryMock.getParsedCommandLineOfConfigFile.mockReturnValue(mockParsedCmd); + + const result2 = provider.getByConfigFilename(configFilename); + + expect(result2.options.exclude).toEqual([]); + }); + + it('should correctly parse the config from the real TS binary', () => { + const configFilename = 'tsconfig.json'; + const configPath = join(process.cwd(), configFilename); + const tsconfigContent = JSON.stringify({ + compilerOptions: { + outDir: 'dist', + }, + exclude: ['node_modules', 'dist'], + }); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue(tsconfigContent); + typescriptLoaderMock.load.mockRestore(); + + const result = provider.getByConfigFilename(configFilename); + + expect(result.options.outDir).toContain('dist'); + expect(result.options.exclude).toEqual(['node_modules', 'dist']); + expect(result.fileNames).toBeDefined(); + + expect(fs.readFileSync).toHaveBeenCalledWith(configPath); + }); + }); +});