Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions actions/build.action.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TsConfigProviderOutput type is imported from '../lib/compiler/helpers/tsconfig-provider' at line 27, but TsConfigProvider is already imported from the same module at line 10. These two imports from the same module should be combined into a single import statement. Additionally, the new import is placed after the webpack require statement, breaking the grouping of ES module imports. It should be merged with the existing import at line 10.

Copilot uses AI. Check for mistakes.

export class BuildAction extends AbstractAction {
protected readonly pluginsLoader = new PluginsLoader();
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions lib/compiler/defaults/swc-defaults.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions lib/compiler/helpers/tsconfig-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 };
}
}
4 changes: 2 additions & 2 deletions lib/compiler/swc/swc-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
130 changes: 130 additions & 0 deletions test/lib/compiler/defaults/swc-defaults.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
139 changes: 139 additions & 0 deletions test/lib/compiler/helpers/tsconfig-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion expect(fs.readFileSync).toHaveBeenCalledWith(configPath) is fragile because TypeScript's internal sys.readFile calls fs.readFileSync with additional arguments (e.g., an encoding parameter like 'utf8'). The toHaveBeenCalledWith matcher requires an exact argument match, so this assertion will fail when TypeScript passes the encoding argument. Consider using expect(fs.readFileSync).toHaveBeenCalledWith(configPath, expect.anything()) or toHaveBeenCalledWith(expect.stringContaining('tsconfig.json'), expect.anything()), or replace the assertion with toHaveBeenCalled() since the actual content verification is done through the returned result.

Copilot uses AI. Check for mistakes.
});
});
});