diff --git a/actions/build.action.ts b/actions/build.action.ts index ab14235ce..baa541bc8 100644 --- a/actions/build.action.ts +++ b/actions/build.action.ts @@ -135,10 +135,10 @@ export class BuildAction extends AbstractAction { 'typeCheck', commandOptions, ); - if (typeCheck && builder.type !== 'swc') { + if (typeCheck && !['swc', 'oxc'].includes(builder.type)) { console.warn( INFO_PREFIX + - ` "typeCheck" will not have any effect when "builder" is not "swc".`, + ` "typeCheck" will not have any effect when "builder" is not "swc" or "oxc".`, ); } @@ -175,10 +175,53 @@ export class BuildAction extends AbstractAction { onSuccess, ); break; + case 'oxc': + await this.runOxc( + configuration, + appName, + pathToTsconfig, + watchMode, + commandOptions, + tsOptions, + onSuccess, + ); + break; } } } + private async runOxc( + configuration: Required, + appName: string | undefined, + pathToTsconfig: string, + watchMode: boolean, + options: Input[], + tsOptions: ts.CompilerOptions, + onSuccess: (() => void) | undefined, + ) { + const { OxcCompiler } = await import('../lib/compiler/oxc/oxc-compiler'); + const oxc = new OxcCompiler(this.pluginsLoader); + + await oxc.run( + configuration, + pathToTsconfig, + appName, + { + watch: watchMode, + typeCheck: getValueOrDefault( + configuration, + 'compilerOptions.typeCheck', + appName, + 'typeCheck', + options, + ), + tsOptions, + assetsManager: this.assetsManager, + }, + onSuccess, + ); + } + private async runSwc( configuration: Required, appName: string | undefined, @@ -220,9 +263,8 @@ export class BuildAction extends AbstractAction { watchMode: boolean, onSuccess: (() => void) | undefined, ) { - const { WebpackCompiler } = await import( - '../lib/compiler/webpack-compiler' - ); + const { WebpackCompiler } = + await import('../lib/compiler/webpack-compiler'); const webpackCompiler = new WebpackCompiler(this.pluginsLoader); const webpackPath = diff --git a/commands/build.command.ts b/commands/build.command.ts index 65de4053b..a22e90e0e 100644 --- a/commands/build.command.ts +++ b/commands/build.command.ts @@ -10,13 +10,16 @@ export class BuildCommand extends AbstractCommand { .option('-c, --config [path]', 'Path to nest-cli configuration file.') .option('-p, --path [path]', 'Path to tsconfig file.') .option('-w, --watch', 'Run in watch mode (live-reload).') - .option('-b, --builder [name]', 'Builder to be used (tsc, webpack, swc).') + .option( + '-b, --builder [name]', + 'Builder to be used (tsc, webpack, swc, oxc).', + ) .option('--watchAssets', 'Watch non-ts (e.g., .graphql) files mode.') .option( '--webpack', 'Use webpack for compilation (deprecated option, use --builder instead).', ) - .option('--type-check', 'Enable type checking (when SWC is used).') + .option('--type-check', 'Enable type checking (when SWC or OXC is used).') .option('--webpackPath [path]', 'Path to webpack configuration.') .option('--tsc', 'Use typescript compiler for compilation.') .option( @@ -46,7 +49,7 @@ export class BuildCommand extends AbstractCommand { value: command.webpackPath, }); - const availableBuilders = ['tsc', 'webpack', 'swc']; + const availableBuilders = ['tsc', 'webpack', 'swc', 'oxc']; if (command.builder && !availableBuilders.includes(command.builder)) { console.error( ERROR_PREFIX + diff --git a/commands/start.command.ts b/commands/start.command.ts index 51db914ad..b6aade4e7 100644 --- a/commands/start.command.ts +++ b/commands/start.command.ts @@ -16,7 +16,10 @@ export class StartCommand extends AbstractCommand { .option('-c, --config [path]', 'Path to nest-cli configuration file.') .option('-p, --path [path]', 'Path to tsconfig file.') .option('-w, --watch', 'Run in watch mode (live-reload).') - .option('-b, --builder [name]', 'Builder to be used (tsc, webpack, swc).') + .option( + '-b, --builder [name]', + 'Builder to be used (tsc, webpack, swc, oxc).', + ) .option('--watchAssets', 'Watch non-ts (e.g., .graphql) files mode.') .option( '-d, --debug [hostport] ', @@ -27,7 +30,7 @@ export class StartCommand extends AbstractCommand { 'Use webpack for compilation (deprecated option, use --builder instead).', ) .option('--webpackPath [path]', 'Path to webpack configuration.') - .option('--type-check', 'Enable type checking (when SWC is used).') + .option('--type-check', 'Enable type checking (when SWC or OXC is used).') .option('--tsc', 'Use typescript compiler for compilation.') .option( '--sourceRoot [sourceRoot]', @@ -104,7 +107,7 @@ export class StartCommand extends AbstractCommand { value: command.envFile, }); - const availableBuilders = ['tsc', 'webpack', 'swc']; + const availableBuilders = ['tsc', 'webpack', 'swc', 'oxc']; if (command.builder && !availableBuilders.includes(command.builder)) { console.error( ERROR_PREFIX + diff --git a/lib/compiler/defaults/oxc-defaults.ts b/lib/compiler/defaults/oxc-defaults.ts new file mode 100644 index 000000000..aa9939cde --- /dev/null +++ b/lib/compiler/defaults/oxc-defaults.ts @@ -0,0 +1,62 @@ +import * as ts from 'typescript'; +import { Configuration } from '../../configuration'; + +export const oxcDefaultsFactory = ( + tsOptions?: ts.CompilerOptions, + configuration?: Configuration, +) => { + const builderOptions = + typeof configuration?.compilerOptions?.builder !== 'string' && + configuration?.compilerOptions?.builder?.type === 'oxc' + ? configuration.compilerOptions.builder.options || {} + : {}; + + const { transformOptions: userTransformOptions, ...cliBuilderOptions } = + builderOptions; + + const useDefineForClassFields = tsOptions?.useDefineForClassFields ?? false; + + return { + transformOptions: { + cwd: process.cwd(), + sourceType: 'unambiguous' as const, + target: 'es2021', + sourcemap: !!(tsOptions?.sourceMap || tsOptions?.inlineSourceMap), + assumptions: { + setPublicClassFields: !useDefineForClassFields, + }, + typescript: { + allowNamespaces: true, + removeClassFieldsWithoutInitializer: !useDefineForClassFields, + }, + decorator: { + legacy: tsOptions?.experimentalDecorators ?? true, + emitDecoratorMetadata: tsOptions?.emitDecoratorMetadata ?? true, + }, + ...userTransformOptions, + }, + cliOptions: { + outDir: tsOptions?.outDir ? convertPath(tsOptions.outDir) : 'dist', + filenames: [configuration?.sourceRoot ?? 'src'], + extensions: ['.js', '.ts'], + quiet: false, + watch: false, + declaration: false, + inlineSourceMap: !!tsOptions?.inlineSourceMap, + rootDir: tsOptions?.rootDir, + stripLeadingPaths: !tsOptions?.rootDir, + ...cliBuilderOptions, + }, + }; +}; + +/** + * Converts Windows specific file paths to posix. + * @param windowsPath + */ +function convertPath(windowsPath: string) { + return windowsPath + .replace(/^\\\\\?\\/, '') + .replace(/\\/g, '/') + .replace(/\/\/+/g, '/'); +} diff --git a/lib/compiler/oxc/constants.ts b/lib/compiler/oxc/constants.ts new file mode 100644 index 000000000..2eb4ced89 --- /dev/null +++ b/lib/compiler/oxc/constants.ts @@ -0,0 +1 @@ +export const OXC_LOG_PREFIX = 'OXC'; diff --git a/lib/compiler/oxc/oxc-compiler.ts b/lib/compiler/oxc/oxc-compiler.ts new file mode 100644 index 000000000..7244bafc5 --- /dev/null +++ b/lib/compiler/oxc/oxc-compiler.ts @@ -0,0 +1,451 @@ +import { cyan } from 'ansis'; +import { fork } from 'child_process'; +import * as chokidar from 'chokidar'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; +import { stat } from 'fs/promises'; +import { sync } from 'glob'; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from 'path'; +import * as ts from 'typescript'; +import { Configuration } from '../../configuration'; +import { ERROR_PREFIX } from '../../ui'; +import { treeKillSync } from '../../utils/tree-kill'; +import { AssetsManager } from '../assets-manager'; +import { BaseCompiler } from '../base-compiler'; +import { oxcDefaultsFactory } from '../defaults/oxc-defaults'; +import { PluginMetadataGenerator } from '../plugins/plugin-metadata-generator'; +import { PluginsLoader } from '../plugins/plugins-loader'; +import { + FOUND_NO_ISSUES_GENERATING_METADATA, + FOUND_NO_ISSUES_METADATA_GENERATION_SKIPPED, +} from '../swc/constants'; +import { TypeCheckerHost } from '../swc/type-checker-host'; +import { OXC_LOG_PREFIX } from './constants'; + +export type OxcCompilerExtras = { + watch: boolean; + typeCheck: boolean; + assetsManager: AssetsManager; + tsOptions: ts.CompilerOptions; +}; + +type OxcTransformResult = { + code: string; + map?: unknown; + declaration?: string; + declarationMap?: unknown; + errors: Array<{ + message: string; + codeframe?: string | null; + }>; +}; + +type OxcTransform = { + transform: ( + filename: string, + sourceText: string, + options?: Record, + ) => Promise | OxcTransformResult; +}; + +type OxcOptions = ReturnType; + +export class OxcCompiler extends BaseCompiler { + private readonly pluginMetadataGenerator = new PluginMetadataGenerator(); + private readonly typeCheckerHost = new TypeCheckerHost(); + + constructor(pluginsLoader: PluginsLoader) { + super(pluginsLoader); + } + + public async run( + configuration: Required, + tsConfigPath: string, + appName: string | undefined, + extras: OxcCompilerExtras, + onSuccess?: () => void, + ) { + const oxcOptions = oxcDefaultsFactory(extras.tsOptions, configuration); + + if (extras.watch) { + if (extras.typeCheck) { + this.runTypeChecker(configuration, tsConfigPath, appName, extras); + } + await this.runOxc(oxcOptions, extras); + + if (onSuccess) { + onSuccess(); + + const debounceTime = 150; + const callback = this.debounce(onSuccess, debounceTime); + await this.watchFilesInSrcDir(oxcOptions, async (file, action) => { + if (action === 'unlink') { + this.removeCompiledFile(file, oxcOptions); + } else { + await this.transpileFile(file, oxcOptions); + } + callback(); + }); + } + } else { + if (extras.typeCheck) { + await this.runTypeChecker(configuration, tsConfigPath, appName, extras); + } + await this.runOxc(oxcOptions, extras); + if (onSuccess) { + onSuccess(); + } + + await extras.assetsManager?.closeWatchers(); + } + } + + private runTypeChecker( + configuration: Required, + tsConfigPath: string, + appName: string | undefined, + extras: OxcCompilerExtras, + ) { + if (extras.watch) { + const args = [ + tsConfigPath, + appName ?? 'undefined', + configuration.sourceRoot ?? 'src', + JSON.stringify(configuration.compilerOptions.plugins ?? []), + ]; + + const childProcessRef = fork( + join(__dirname, '../swc/forked-type-checker.js'), + args, + { + cwd: process.cwd(), + }, + ); + process.on( + 'exit', + () => childProcessRef && treeKillSync(childProcessRef.pid!), + ); + } else { + const { readonlyVisitors } = this.loadPlugins( + configuration, + tsConfigPath, + appName, + ); + const outputDir = this.getPathToSource( + configuration, + tsConfigPath, + appName, + ); + + let fulfilled = false; + return new Promise((resolve, reject) => { + try { + this.typeCheckerHost.run(tsConfigPath, { + watch: extras.watch, + onTypeCheck: (program) => { + if (!fulfilled) { + fulfilled = true; + resolve(); + } + if (readonlyVisitors.length > 0) { + process.nextTick(() => + console.log(FOUND_NO_ISSUES_GENERATING_METADATA), + ); + + this.pluginMetadataGenerator.generate({ + outputDir, + visitors: readonlyVisitors, + tsProgramRef: program, + }); + } else { + process.nextTick(() => + console.log(FOUND_NO_ISSUES_METADATA_GENERATION_SKIPPED), + ); + } + }, + }); + } catch (err) { + if (!fulfilled) { + fulfilled = true; + reject(err); + } + } + }); + } + } + + private async runOxc(options: OxcOptions, _extras: OxcCompilerExtras) { + if (!options.cliOptions.quiet) { + process.nextTick(() => console.log(OXC_LOG_PREFIX, cyan('Running...'))); + } + + const files = await this.getFilesToCompile(options); + await Promise.all(files.map((file) => this.transpileFile(file, options))); + } + + private async loadOxcTransform(): Promise { + try { + try { + const oxc = require('oxc-transform'); + return oxc.default ?? oxc; + } catch { + // oxc-transform is ESM in supported versions. Keep a native dynamic + // import fallback for Node versions that cannot require ESM packages. + } + + const nativeImport = new Function( + 'specifier', + 'return import(specifier)', + ) as (specifier: string) => Promise; + const oxc = await nativeImport('oxc-transform'); + return oxc.default ?? oxc; + } catch (err) { + console.error( + ERROR_PREFIX + + ' Failed to load "oxc-transform" required package. Please, make sure to install both "oxc-transform" and "@oxc-project/runtime" as development dependencies.', + ); + process.exit(1); + } + } + + private async transpileFile(file: string, options: OxcOptions) { + const oxc = await this.loadOxcTransform(); + const sourceText = readFileSync(file, 'utf8'); + const result = await oxc.transform(file, sourceText, { + ...options.transformOptions, + lang: this.getLangByExtension(file), + typescript: { + ...options.transformOptions.typescript, + declaration: options.cliOptions.declaration + ? { sourcemap: !!options.transformOptions.sourcemap } + : undefined, + }, + }); + + if (result.errors.length > 0) { + const message = result.errors + .map((error) => error.codeframe || error.message) + .join('\n'); + throw new Error(message); + } + + const outputPath = this.getOutputPath(file, options, '.js'); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync( + outputPath, + this.withSourceMap( + result.code, + result.map, + options, + `${basename(outputPath)}.map`, + ), + ); + + if (result.map && !this.isInlineSourceMap(options)) { + writeFileSync(`${outputPath}.map`, JSON.stringify(result.map)); + } + + if (result.declaration) { + const declarationPath = this.getOutputPath(file, options, '.d.ts'); + mkdirSync(dirname(declarationPath), { recursive: true }); + writeFileSync( + declarationPath, + this.withSourceMap( + result.declaration, + result.declarationMap, + options, + `${basename(declarationPath)}.map`, + ), + ); + + if (result.declarationMap && !this.isInlineSourceMap(options)) { + writeFileSync( + `${declarationPath}.map`, + JSON.stringify(result.declarationMap), + ); + } + } + } + + private async getFilesToCompile(options: OxcOptions) { + const files = options.cliOptions.filenames.flatMap((filename) => { + if (!existsSync(filename)) { + return sync(filename, { dot: true }); + } + + if (existsSync(filename) && !this.isDirectory(filename)) { + return [filename]; + } + + const directory = filename.replace(/\\/g, '/'); + return sync(`${directory}/**/*`, { dot: true }); + }); + + const uniqueFiles = [...new Set(files)]; + const filteredFiles = await Promise.all( + uniqueFiles.map(async (file) => { + const isFile = await stat(file) + .then((stats) => stats.isFile()) + .catch(() => false); + return isFile && this.isSupportedFile(file, options) ? file : undefined; + }), + ); + + return filteredFiles.filter(Boolean) as string[]; + } + + private isDirectory(path: string) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } + } + + private async watchFilesInSrcDir( + options: OxcOptions, + onChange: (file: string, action: 'add' | 'change' | 'unlink') => unknown, + ) { + const srcDir = options.cliOptions?.filenames?.[0]; + const isDirectory = await stat(srcDir) + .then((stats) => stats.isDirectory()) + .catch(() => false); + + if (!srcDir || !isDirectory) { + return; + } + + const watcher = chokidar.watch(srcDir, { + ignored: (file, stats) => + (stats?.isFile() && !this.isSupportedFile(file, options)) as boolean, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 10, + }, + }); + + for (const event of ['add', 'change', 'unlink'] as const) { + watcher.on(event, async (file) => onChange(file, event)); + } + } + + private removeCompiledFile(file: string, options: OxcOptions) { + const outputPath = this.getOutputPath(file, options, '.js'); + rmSync(outputPath, { force: true }); + rmSync(`${outputPath}.map`, { force: true }); + + const declarationPath = this.getOutputPath(file, options, '.d.ts'); + rmSync(declarationPath, { force: true }); + rmSync(`${declarationPath}.map`, { force: true }); + } + + private getOutputPath(file: string, options: OxcOptions, extension: string) { + const outDir = isAbsolute(options.cliOptions.outDir!) + ? options.cliOptions.outDir! + : join(process.cwd(), options.cliOptions.outDir!); + const baseDir = this.getBaseDir(file, options); + const relativePath = relative(baseDir, resolve(process.cwd(), file)); + const parsedExtension = this.getOutputExtension(file, extension); + + return join( + outDir, + relativePath.replace(/\.(c|m)?(j|t)sx?$/, parsedExtension), + ); + } + + private getBaseDir(file: string, options: OxcOptions) { + if (!options.cliOptions.stripLeadingPaths) { + return resolve(process.cwd(), options.cliOptions.rootDir || '.'); + } + + const filename = options.cliOptions.filenames.find((item) => { + const resolved = resolve(process.cwd(), item); + return resolve(process.cwd(), file).startsWith(resolved); + }); + + return filename ? resolve(process.cwd(), filename) : process.cwd(); + } + + private getOutputExtension(file: string, extension: string) { + if (extension !== '.js') { + return extension; + } + + if (file.endsWith('.mts')) { + return '.mjs'; + } + if (file.endsWith('.cts')) { + return '.cjs'; + } + return '.js'; + } + + private getLangByExtension(file: string) { + const extension = extname(file); + if (extension === '.tsx') { + return 'tsx'; + } + if (extension === '.jsx') { + return 'jsx'; + } + if (extension === '.ts' || extension === '.mts' || extension === '.cts') { + return 'ts'; + } + return 'js'; + } + + private isSupportedFile(file: string, options: OxcOptions) { + if (file.endsWith('.d.ts')) { + return false; + } + return options.cliOptions.extensions.some((extension) => + file.endsWith(extension), + ); + } + + private withSourceMap( + code: string, + map: unknown, + options: OxcOptions, + sourceMapFilename: string, + ) { + if (!map) { + return code; + } + + if (this.isInlineSourceMap(options)) { + return `${code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from( + JSON.stringify(map), + ).toString('base64')}`; + } + + return `${code}\n//# sourceMappingURL=${sourceMapFilename}`; + } + + private isInlineSourceMap(options: OxcOptions) { + return options.cliOptions.inlineSourceMap === true; + } + + private debounce(callback: () => void, wait: number) { + let timeout: NodeJS.Timeout; + return () => { + clearTimeout(timeout); + timeout = setTimeout(callback, wait); + }; + } +} diff --git a/lib/configuration/configuration.ts b/lib/configuration/configuration.ts index 8508a4357..49f61c749 100644 --- a/lib/configuration/configuration.ts +++ b/lib/configuration/configuration.ts @@ -27,6 +27,17 @@ export interface SwcBuilderOptions { quiet?: boolean; } +export interface OxcBuilderOptions { + outDir?: string; + filenames?: string[]; + extensions?: string[]; + quiet?: boolean; + declaration?: boolean; + stripLeadingPaths?: boolean; + rootDir?: string; + transformOptions?: Record; +} + export interface WebpackBuilderOptions { configPath?: string; } @@ -35,7 +46,7 @@ export interface TscBuilderOptions { configPath?: string; } -export type BuilderVariant = 'tsc' | 'swc' | 'webpack'; +export type BuilderVariant = 'tsc' | 'swc' | 'oxc' | 'webpack'; export type Builder = | BuilderVariant | { @@ -46,6 +57,10 @@ export type Builder = type: 'swc'; options?: SwcBuilderOptions; } + | { + type: 'oxc'; + options?: OxcBuilderOptions; + } | { type: 'tsc'; options?: TscBuilderOptions; diff --git a/package-lock.json b/package-lock.json index 50c66ce03..6aab543fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,14 +55,18 @@ "release-it": "19.2.4", "ts-jest": "29.4.9", "ts-loader": "9.5.7", - "ts-node": "10.9.2" + "ts-node": "10.9.2", + "@oxc-project/runtime": "0.89.0", + "oxc-transform": "0.89.0" }, "engines": { "node": ">= 20.11" }, "peerDependencies": { "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", - "@swc/core": "^1.3.62" + "@swc/core": "^1.3.62", + "@oxc-project/runtime": "^0.89.0", + "oxc-transform": "^0.89.0" }, "peerDependenciesMeta": { "@swc/cli": { @@ -70,6 +74,12 @@ }, "@swc/core": { "optional": true + }, + "@oxc-project/runtime": { + "optional": true + }, + "oxc-transform": { + "optional": true } } }, @@ -12543,6 +12553,319 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.89.0.tgz", + "integrity": "sha512-vP7SaoF0l09GAYuj4IKjfyJodRWC09KdLy8NmnsdUPAsWhPz+2hPTLfEr5+iObDXSNug1xfTxtkGjBLvtwBOPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@oxc-transform/binding-android-arm64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.89.0.tgz", + "integrity": "sha512-ClAT3Uwuo++nxwJxKpxiav3jKIRzMSc0mHBoEvBFQgY4rjjwNe13N6ktFwJH6paslQNfJ8NhrzbCgUcj9sFpvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-arm64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.89.0.tgz", + "integrity": "sha512-p8ZPY/9A/sbxEZnpFhpFXcmBKESyeGsM+W0uHcP/y2q+esy7aXx4UZlT64zdxqgtepPK7e2LFYYS4RdJTLdM0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-x64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.89.0.tgz", + "integrity": "sha512-GvR99rtmk90Jk9HjxVEHrtndldVaQ2FxJaESzg/zaQaCAN50R1WR8mzfIQN5Pdi5ZFM3dUgxVhVbWmSc6SbpmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-freebsd-x64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.89.0.tgz", + "integrity": "sha512-wCNAlWW6ylqQxFFwcOkrQ77HeVaxrsHlKH+POXB1ercbb+OXARhAQaZG7crGGad8RTn1HwkudAl4W7XwcwW8ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-gnueabihf": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.89.0.tgz", + "integrity": "sha512-7nfOV49v/NsKkcRaMf0RXtnTtYZV+DXgptaBdkGBrqJAtUQ6uvDERJLzRMMiHKfl8qXpvguMjLwCPELDMtLaqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-musleabihf": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.89.0.tgz", + "integrity": "sha512-lOSBrPpTTpF2Jr3in2JL4WymKm8V5hPnniVTwhZcpecaBBKmK+qYW88FJJuHkO3uZYx7ZwfkEb0FN7S81hQ2Fw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.89.0.tgz", + "integrity": "sha512-fDI14uxb+sm0lcbrRdtm5cvitxNwQ+271hMVqeMm5gUkDCbY0agGvdvbEq/hBuMaMh7BL2uU9fmxOmdRgfmAqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-musl": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.89.0.tgz", + "integrity": "sha512-9OACjq3oRywMaSW5SQDz6Y2AS62EQ0nkLor7P2YSdnte1LbrciwfqMYm8v6HRJ8Pqif2iqtAc9YlU8OtefSvkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-riscv64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.89.0.tgz", + "integrity": "sha512-or2lJ/Uhjaj/Hbz3f741cZM+FKZNwKL20wELOro0xraL7NwThHTHEZGQrSHlJ/VkRdJPuo+UC3xB9OgdY6vvFw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-s390x-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.89.0.tgz", + "integrity": "sha512-HdC3Z3CTveXgMu13wF6qqjpnH7/Ds9BeOEYU5xGPXAFkTLMxL6miYiLN+kgTekTZeYPphM3Dnx/+Fwrmi1ukDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.89.0.tgz", + "integrity": "sha512-w5xtFpVyUEPo0/gLInZ+NbP5pT6+CG1ukoyZYr3Y+J8cz+xezNokzjeAu4p7MmG6XG63DKBG60dlBVL1HCgtWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-musl": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.89.0.tgz", + "integrity": "sha512-MxtHJp0HwH1F6gfAnAtr8W9gD03Hnj9DIlaFcqCXYCEvvzpxo/8kG5E3Y2UCOfR1h2rV2haX4k7+o5Swr+/dMw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-wasm32-wasi": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-wasm32-wasi/-/binding-wasm32-wasi-0.89.0.tgz", + "integrity": "sha512-YPkhOluELfgPgY02RqGwc0Q17HGVNSw3bHJ60pc4IppXAbigXwX+IpWYYowcMtWOr6zAawUAxakUEeLb5yv3pg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-win32-arm64-msvc": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.89.0.tgz", + "integrity": "sha512-gsuSbYzBcqfKx7X7c1rZJGskmXDlLMkwm4JmhCVBG91L14j+9eFIOdXk3U6E5/OtiFMtr4WIDlSApEfhDh78Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-win32-x64-msvc": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.89.0.tgz", + "integrity": "sha512-5HYV5lUVQ/3NJw4QVtgCaPy+6us+YlVxKWE934ulGGlnwZvTgMvsSXiwNdJwkgWhLcfv7Y1NtkFvcMDBi1Gpzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/oxc-transform": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.89.0.tgz", + "integrity": "sha512-65rusuqi/g5tBB0gcZjM/U9/tSGvaiapus55RXE1AmnTPPEAljSF0z0sm4xDPwCskDPRcvSiJ/cdracCJQ17Sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-transform/binding-android-arm64": "0.89.0", + "@oxc-transform/binding-darwin-arm64": "0.89.0", + "@oxc-transform/binding-darwin-x64": "0.89.0", + "@oxc-transform/binding-freebsd-x64": "0.89.0", + "@oxc-transform/binding-linux-arm-gnueabihf": "0.89.0", + "@oxc-transform/binding-linux-arm-musleabihf": "0.89.0", + "@oxc-transform/binding-linux-arm64-gnu": "0.89.0", + "@oxc-transform/binding-linux-arm64-musl": "0.89.0", + "@oxc-transform/binding-linux-riscv64-gnu": "0.89.0", + "@oxc-transform/binding-linux-s390x-gnu": "0.89.0", + "@oxc-transform/binding-linux-x64-gnu": "0.89.0", + "@oxc-transform/binding-linux-x64-musl": "0.89.0", + "@oxc-transform/binding-wasm32-wasi": "0.89.0", + "@oxc-transform/binding-win32-arm64-msvc": "0.89.0", + "@oxc-transform/binding-win32-x64-msvc": "0.89.0" + } } }, "dependencies": { @@ -21441,6 +21764,143 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==" + }, + "@oxc-project/runtime": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.89.0.tgz", + "integrity": "sha512-vP7SaoF0l09GAYuj4IKjfyJodRWC09KdLy8NmnsdUPAsWhPz+2hPTLfEr5+iObDXSNug1xfTxtkGjBLvtwBOPQ==", + "dev": true + }, + "@oxc-transform/binding-android-arm64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.89.0.tgz", + "integrity": "sha512-ClAT3Uwuo++nxwJxKpxiav3jKIRzMSc0mHBoEvBFQgY4rjjwNe13N6ktFwJH6paslQNfJ8NhrzbCgUcj9sFpvQ==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-darwin-arm64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.89.0.tgz", + "integrity": "sha512-p8ZPY/9A/sbxEZnpFhpFXcmBKESyeGsM+W0uHcP/y2q+esy7aXx4UZlT64zdxqgtepPK7e2LFYYS4RdJTLdM0w==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-darwin-x64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.89.0.tgz", + "integrity": "sha512-GvR99rtmk90Jk9HjxVEHrtndldVaQ2FxJaESzg/zaQaCAN50R1WR8mzfIQN5Pdi5ZFM3dUgxVhVbWmSc6SbpmQ==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-freebsd-x64": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.89.0.tgz", + "integrity": "sha512-wCNAlWW6ylqQxFFwcOkrQ77HeVaxrsHlKH+POXB1ercbb+OXARhAQaZG7crGGad8RTn1HwkudAl4W7XwcwW8ng==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-arm-gnueabihf": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.89.0.tgz", + "integrity": "sha512-7nfOV49v/NsKkcRaMf0RXtnTtYZV+DXgptaBdkGBrqJAtUQ6uvDERJLzRMMiHKfl8qXpvguMjLwCPELDMtLaqA==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-arm-musleabihf": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.89.0.tgz", + "integrity": "sha512-lOSBrPpTTpF2Jr3in2JL4WymKm8V5hPnniVTwhZcpecaBBKmK+qYW88FJJuHkO3uZYx7ZwfkEb0FN7S81hQ2Fw==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-arm64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.89.0.tgz", + "integrity": "sha512-fDI14uxb+sm0lcbrRdtm5cvitxNwQ+271hMVqeMm5gUkDCbY0agGvdvbEq/hBuMaMh7BL2uU9fmxOmdRgfmAqg==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-arm64-musl": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.89.0.tgz", + "integrity": "sha512-9OACjq3oRywMaSW5SQDz6Y2AS62EQ0nkLor7P2YSdnte1LbrciwfqMYm8v6HRJ8Pqif2iqtAc9YlU8OtefSvkA==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-riscv64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.89.0.tgz", + "integrity": "sha512-or2lJ/Uhjaj/Hbz3f741cZM+FKZNwKL20wELOro0xraL7NwThHTHEZGQrSHlJ/VkRdJPuo+UC3xB9OgdY6vvFw==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-s390x-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.89.0.tgz", + "integrity": "sha512-HdC3Z3CTveXgMu13wF6qqjpnH7/Ds9BeOEYU5xGPXAFkTLMxL6miYiLN+kgTekTZeYPphM3Dnx/+Fwrmi1ukDw==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-x64-gnu": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.89.0.tgz", + "integrity": "sha512-w5xtFpVyUEPo0/gLInZ+NbP5pT6+CG1ukoyZYr3Y+J8cz+xezNokzjeAu4p7MmG6XG63DKBG60dlBVL1HCgtWA==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-linux-x64-musl": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.89.0.tgz", + "integrity": "sha512-MxtHJp0HwH1F6gfAnAtr8W9gD03Hnj9DIlaFcqCXYCEvvzpxo/8kG5E3Y2UCOfR1h2rV2haX4k7+o5Swr+/dMw==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-wasm32-wasi": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-wasm32-wasi/-/binding-wasm32-wasi-0.89.0.tgz", + "integrity": "sha512-YPkhOluELfgPgY02RqGwc0Q17HGVNSw3bHJ60pc4IppXAbigXwX+IpWYYowcMtWOr6zAawUAxakUEeLb5yv3pg==", + "dev": true, + "optional": true, + "requires": { + "@napi-rs/wasm-runtime": "^1.0.5" + } + }, + "@oxc-transform/binding-win32-arm64-msvc": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.89.0.tgz", + "integrity": "sha512-gsuSbYzBcqfKx7X7c1rZJGskmXDlLMkwm4JmhCVBG91L14j+9eFIOdXk3U6E5/OtiFMtr4WIDlSApEfhDh78Rw==", + "dev": true, + "optional": true + }, + "@oxc-transform/binding-win32-x64-msvc": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.89.0.tgz", + "integrity": "sha512-5HYV5lUVQ/3NJw4QVtgCaPy+6us+YlVxKWE934ulGGlnwZvTgMvsSXiwNdJwkgWhLcfv7Y1NtkFvcMDBi1Gpzg==", + "dev": true, + "optional": true + }, + "oxc-transform": { + "version": "0.89.0", + "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.89.0.tgz", + "integrity": "sha512-65rusuqi/g5tBB0gcZjM/U9/tSGvaiapus55RXE1AmnTPPEAljSF0z0sm4xDPwCskDPRcvSiJ/cdracCJQ17Sg==", + "dev": true, + "requires": { + "@oxc-transform/binding-android-arm64": "0.89.0", + "@oxc-transform/binding-darwin-arm64": "0.89.0", + "@oxc-transform/binding-darwin-x64": "0.89.0", + "@oxc-transform/binding-freebsd-x64": "0.89.0", + "@oxc-transform/binding-linux-arm-gnueabihf": "0.89.0", + "@oxc-transform/binding-linux-arm-musleabihf": "0.89.0", + "@oxc-transform/binding-linux-arm64-gnu": "0.89.0", + "@oxc-transform/binding-linux-arm64-musl": "0.89.0", + "@oxc-transform/binding-linux-riscv64-gnu": "0.89.0", + "@oxc-transform/binding-linux-s390x-gnu": "0.89.0", + "@oxc-transform/binding-linux-x64-gnu": "0.89.0", + "@oxc-transform/binding-linux-x64-musl": "0.89.0", + "@oxc-transform/binding-wasm32-wasi": "0.89.0", + "@oxc-transform/binding-win32-arm64-msvc": "0.89.0", + "@oxc-transform/binding-win32-x64-msvc": "0.89.0" + } } } } diff --git a/package.json b/package.json index c4493c970..9e3d1dd1a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@commitlint/config-angular": "20.5.3", "@swc/cli": "0.8.1", "@swc/core": "1.15.33", + "@oxc-project/runtime": "0.89.0", "@types/inquirer": "9.0.9", "@types/jest": "29.5.14", "@types/node": "24.12.2", @@ -77,6 +78,7 @@ "husky": "9.1.7", "jest": "29.7.0", "lint-staged": "16.4.0", + "oxc-transform": "0.89.0", "prettier": "3.8.3", "release-it": "20.0.1", "ts-jest": "29.4.9", @@ -88,7 +90,9 @@ }, "peerDependencies": { "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", - "@swc/core": "^1.3.62" + "@swc/core": "^1.3.62", + "@oxc-project/runtime": "^0.89.0", + "oxc-transform": "^0.89.0" }, "peerDependenciesMeta": { "@swc/cli": { @@ -96,6 +100,12 @@ }, "@swc/core": { "optional": true + }, + "@oxc-project/runtime": { + "optional": true + }, + "oxc-transform": { + "optional": true } } -} +} \ No newline at end of file diff --git a/test/actions/build.action.spec.ts b/test/actions/build.action.spec.ts new file mode 100644 index 000000000..c985c1abe --- /dev/null +++ b/test/actions/build.action.spec.ts @@ -0,0 +1,105 @@ +import { BuildAction } from '../../actions/build.action'; +import { OxcCompiler } from '../../lib/compiler/oxc/oxc-compiler'; + +jest.mock('../../lib/compiler/helpers/delete-out-dir', () => ({ + deleteOutDirIfEnabled: jest.fn(), +})); + +jest.mock('../../lib/compiler/oxc/oxc-compiler', () => ({ + OxcCompiler: jest.fn().mockImplementation(() => ({ + run: jest.fn(), + })), +})); + +describe('BuildAction', () => { + let buildAction: BuildAction; + let oxcRunMock: jest.Mock; + + const commandInputs = [{ name: 'app', value: undefined! }]; + const commandOptions = [ + { name: 'config', value: undefined! }, + { name: 'builder', value: 'oxc' }, + { name: 'typeCheck', value: true }, + { name: 'webpack', value: false }, + { name: 'all', value: false }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + + oxcRunMock = jest.fn(); + (OxcCompiler as unknown as jest.Mock).mockImplementation(() => ({ + run: oxcRunMock, + })); + + buildAction = new BuildAction(); + (buildAction as any).loader = { + load: jest.fn().mockResolvedValue({ + sourceRoot: 'src', + compilerOptions: { + builder: 'oxc', + typeCheck: true, + assets: [], + }, + projects: {}, + }), + }; + (buildAction as any).tsConfigProvider = { + getByConfigFilename: jest.fn(() => ({ + options: { + outDir: 'dist', + rootDir: 'src', + }, + })), + }; + (buildAction as any).assetsManager = { + copyAssets: jest.fn(), + closeWatchers: jest.fn(), + }; + }); + + it('should route builder "oxc" to OxcCompiler with type-check and assets extras', async () => { + const onSuccess = jest.fn(); + + await buildAction.runBuild( + commandInputs, + commandOptions, + false, + false, + false, + onSuccess, + ); + + expect(OxcCompiler).toHaveBeenCalledWith( + (buildAction as any).pluginsLoader, + ); + expect(oxcRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceRoot: 'src', + }), + 'tsconfig.json', + undefined, + { + watch: false, + typeCheck: true, + tsOptions: { + outDir: 'dist', + rootDir: 'src', + }, + assetsManager: (buildAction as any).assetsManager, + }, + onSuccess, + ); + }); + + it('should not warn that typeCheck is ignored for the OXC builder', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await buildAction.runBuild(commandInputs, commandOptions, false, false); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('"typeCheck" will not have any effect'), + ); + warnSpy.mockRestore(); + }); +}); diff --git a/test/commands/builder-validation.spec.ts b/test/commands/builder-validation.spec.ts new file mode 100644 index 000000000..ffebdb2ba --- /dev/null +++ b/test/commands/builder-validation.spec.ts @@ -0,0 +1,132 @@ +import { BuildCommand } from '../../commands/build.command'; +import { StartCommand } from '../../commands/start.command'; + +function createProgramMock() { + let actionCallback: Function; + const commandMock: any = {}; + Object.assign(commandMock, { + allowUnknownOption: jest.fn(() => commandMock), + option: jest.fn(() => commandMock), + description: jest.fn(() => commandMock), + action: jest.fn((callback: Function) => { + actionCallback = callback; + return commandMock; + }), + }); + const programMock = { + command: jest.fn(() => commandMock), + rawArgs: [], + options: [], + }; + + return { + programMock, + commandMock, + getAction: () => actionCallback!, + }; +} + +describe('builder command validation', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('should accept "oxc" for nest build --builder', async () => { + const action = { handle: jest.fn() }; + const { programMock, getAction } = createProgramMock(); + new BuildCommand(action as any).load(programMock as any); + + await getAction()([], { + builder: 'oxc', + watch: false, + watchAssets: false, + webpack: false, + tsc: false, + typeCheck: true, + all: false, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(action.handle).toHaveBeenCalledWith( + [{ name: 'app', value: undefined }], + expect.arrayContaining([ + { name: 'builder', value: 'oxc' }, + { name: 'typeCheck', value: true }, + ]), + ); + }); + + it('should list oxc as an available build builder when validation fails', async () => { + const action = { handle: jest.fn() }; + const { programMock, getAction } = createProgramMock(); + new BuildCommand(action as any).load(programMock as any); + + await getAction()([], { + builder: 'invalid', + watch: false, + watchAssets: false, + webpack: false, + tsc: false, + all: false, + }); + + expect(action.handle).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Available builders: tsc, webpack, swc, oxc'), + ); + }); + + it('should accept "oxc" for nest start --builder', async () => { + const action = { handle: jest.fn() }; + const { programMock, getAction } = createProgramMock(); + new StartCommand(action as any).load(programMock as any); + + await getAction()('api', { + builder: 'oxc', + watch: true, + watchAssets: false, + webpack: false, + tsc: false, + typeCheck: true, + shell: true, + envFile: [], + }); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(action.handle).toHaveBeenCalledWith( + [{ name: 'app', value: 'api' }], + expect.arrayContaining([ + { name: 'builder', value: 'oxc' }, + { name: 'typeCheck', value: true }, + ]), + expect.any(Array), + ); + }); + + it('should list oxc as an available start builder when validation fails', async () => { + const action = { handle: jest.fn() }; + const { programMock, getAction } = createProgramMock(); + new StartCommand(action as any).load(programMock as any); + + await getAction()('api', { + builder: 'invalid', + watch: false, + watchAssets: false, + webpack: false, + tsc: false, + shell: true, + envFile: [], + }); + + expect(action.handle).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Available builders: tsc, webpack, swc, oxc'), + ); + }); +}); diff --git a/test/lib/compiler/defaults/oxc-defaults.spec.ts b/test/lib/compiler/defaults/oxc-defaults.spec.ts new file mode 100644 index 000000000..a0af189c2 --- /dev/null +++ b/test/lib/compiler/defaults/oxc-defaults.spec.ts @@ -0,0 +1,57 @@ +import { oxcDefaultsFactory } from '../../../../lib/compiler/defaults/oxc-defaults'; + +describe('oxcDefaultsFactory', () => { + it('should set stripLeadingPaths to true when rootDir is not set', () => { + const result = oxcDefaultsFactory({}, undefined); + expect(result.cliOptions.stripLeadingPaths).toBe(true); + }); + + it('should set stripLeadingPaths to false when rootDir is set', () => { + const result = oxcDefaultsFactory({ rootDir: './src' }, undefined); + expect(result.cliOptions.stripLeadingPaths).toBe(false); + }); + + it('should use outDir from tsOptions when provided', () => { + const result = oxcDefaultsFactory({ outDir: 'build' }, undefined); + expect(result.cliOptions.outDir).toBe('build'); + }); + + it('should default outDir to dist when not provided', () => { + const result = oxcDefaultsFactory({}, undefined); + expect(result.cliOptions.outDir).toBe('dist'); + }); + + it('should use sourceRoot from configuration for filenames', () => { + const configuration = { + sourceRoot: 'lib', + }; + const result = oxcDefaultsFactory({}, configuration as any); + expect(result.cliOptions.filenames).toEqual(['lib']); + }); + + it('should enable Nest decorator defaults', () => { + const result = oxcDefaultsFactory({}, undefined); + expect(result.transformOptions.decorator).toEqual({ + legacy: true, + emitDecoratorMetadata: true, + }); + }); + + it('should allow user to override transform options', () => { + const configuration = { + compilerOptions: { + builder: { + type: 'oxc' as const, + options: { + transformOptions: { + target: 'es2022', + }, + }, + }, + }, + }; + + const result = oxcDefaultsFactory({}, configuration as any); + expect(result.transformOptions.target).toBe('es2022'); + }); +}); diff --git a/test/lib/compiler/oxc/oxc-compiler.spec.ts b/test/lib/compiler/oxc/oxc-compiler.spec.ts new file mode 100644 index 000000000..056e5b29d --- /dev/null +++ b/test/lib/compiler/oxc/oxc-compiler.spec.ts @@ -0,0 +1,454 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { PluginsLoader } from '../../../../lib/compiler/plugins/plugins-loader'; +import { OxcCompiler } from '../../../../lib/compiler/oxc/oxc-compiler'; +import * as oxcDefaults from '../../../../lib/compiler/defaults/oxc-defaults'; + +jest.mock('chokidar'); + +describe('OXC Compiler', () => { + let compiler: OxcCompiler; + let oxcDefaultsFactoryMock = jest.fn(); + const cwd = process.cwd(); + const actualOxcDefaultsFactory = jest.requireActual( + '../../../../lib/compiler/defaults/oxc-defaults', + ).oxcDefaultsFactory; + + const callRunCompiler = async ({ + configuration, + tsconfig, + appName, + extras, + onSuccess, + }: any) => { + return await compiler.run( + configuration || {}, + tsconfig || '', + appName || '', + extras || {}, + onSuccess ?? jest.fn(), + ); + }; + + beforeEach(() => { + const PluginsLoaderStub = { + load: () => jest.fn(), + resolvePluginReferences: () => jest.fn(), + } as unknown as PluginsLoader; + + compiler = new OxcCompiler(PluginsLoaderStub); + + (oxcDefaults as any).oxcDefaultsFactory = oxcDefaultsFactoryMock; + + compiler['runOxc'] = jest.fn(); + compiler['runTypeChecker'] = jest.fn(); + compiler['debounce'] = jest.fn(); + compiler['watchFilesInSrcDir'] = jest.fn(); + + jest.clearAllMocks(); + }); + + afterEach(() => { + process.chdir(cwd); + }); + + describe('run', () => { + it('should call oxcDefaultsFactory with tsOptions and configuration', async () => { + const fixture = { + extras: { + tsOptions: { + _tsOptionsTest: {}, + }, + }, + configuration: { + _configurationTest: {}, + }, + }; + + await callRunCompiler({ + configuration: fixture.configuration, + extras: fixture.extras, + }); + expect(oxcDefaultsFactoryMock).toHaveBeenCalledWith( + fixture.extras.tsOptions, + fixture.configuration, + ); + }); + + it('should not call runTypeChecker when extras.typeCheck is false', async () => { + const fixture = { + extras: { + watch: false, + typeCheck: false, + tsOptions: null, + }, + }; + + fixture.extras.watch = true; + await callRunCompiler({ + extras: fixture.extras, + }); + + fixture.extras.watch = false; + await callRunCompiler({ + extras: fixture.extras, + }); + + expect(compiler['runTypeChecker']).not.toHaveBeenCalled(); + }); + + it('should call runTypeChecker when extras.typeCheck is true', async () => { + const fixture = { + configuration: '_configurationTest', + tsConfigPath: 'tsConfigPathTest', + appName: 'appNameTest', + extras: { + watch: false, + typeCheck: true, + tsOptions: null, + }, + }; + + fixture.extras.watch = true; + await callRunCompiler({ + configuration: fixture.configuration, + extras: fixture.extras, + appName: fixture.appName, + tsconfig: fixture.tsConfigPath, + }); + + fixture.extras.watch = false; + await callRunCompiler({ + configuration: fixture.configuration, + extras: fixture.extras, + appName: fixture.appName, + tsconfig: fixture.tsConfigPath, + }); + + expect(compiler['runTypeChecker']).toHaveBeenCalledTimes(2); + expect(compiler['runTypeChecker']).toHaveBeenCalledWith( + fixture.configuration, + fixture.tsConfigPath, + fixture.appName, + fixture.extras, + ); + }); + + it('should call runOxc', async () => { + oxcDefaultsFactoryMock.mockReturnValueOnce('oxcOptionsTest'); + + const fixture = { + extras: { + watch: false, + }, + }; + + fixture.extras.watch = true; + await callRunCompiler({ + extras: fixture.extras, + }); + + expect(compiler['runOxc']).toHaveBeenCalledWith( + 'oxcOptionsTest', + fixture.extras, + ); + + fixture.extras.watch = false; + await callRunCompiler({ + extras: fixture.extras, + }); + + expect(compiler['runOxc']).toHaveBeenCalledWith( + 'oxcOptionsTest', + fixture.extras, + ); + }); + }); + + describe('transpileFile', () => { + it('should write transformed output to outDir', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync(join(directory, 'src', 'main.ts'), 'const value = 1;'); + process.chdir(directory); + + const transform = jest.fn().mockResolvedValue({ + code: 'const value = 1;\n', + errors: [], + }); + compiler['loadOxcTransform'] = jest.fn().mockResolvedValue({ transform }); + + const options = actualOxcDefaultsFactory({ rootDir: 'src' }, { + sourceRoot: 'src', + } as any); + + await compiler['transpileFile']('src/main.ts', options); + + expect(transform).toHaveBeenCalledWith( + 'src/main.ts', + 'const value = 1;', + expect.objectContaining({ + lang: 'ts', + }), + ); + expect(readFileSync(join(directory, 'dist', 'main.js'), 'utf8')).toBe( + 'const value = 1;\n', + ); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should throw when oxc reports errors', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync(join(directory, 'src', 'main.ts'), 'const value = ;'); + process.chdir(directory); + + compiler['loadOxcTransform'] = jest.fn().mockResolvedValue({ + transform: jest.fn().mockResolvedValue({ + code: '', + errors: [{ message: 'Unexpected token' }], + }), + }); + + const options = actualOxcDefaultsFactory({}, { + sourceRoot: 'src', + } as any); + + await expect( + compiler['transpileFile']('src/main.ts', options), + ).rejects.toThrow('Unexpected token'); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should emit external source maps and declaration files when configured', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync(join(directory, 'src', 'service.ts'), 'export class S {}'); + process.chdir(directory); + + compiler['loadOxcTransform'] = jest.fn().mockResolvedValue({ + transform: jest.fn().mockResolvedValue({ + code: 'export class S {}\n', + map: { + version: 3, + sources: ['src/service.ts'], + names: [], + mappings: '', + }, + declaration: 'export declare class S {}\n', + declarationMap: { + version: 3, + sources: ['src/service.ts'], + names: [], + mappings: '', + }, + errors: [], + }), + }); + + const options = actualOxcDefaultsFactory( + { + rootDir: 'src', + sourceMap: true, + }, + { + sourceRoot: 'src', + compilerOptions: { + builder: { + type: 'oxc', + options: { + declaration: true, + }, + }, + }, + } as any, + ); + + await compiler['transpileFile']('src/service.ts', options); + + expect(readFileSync(join(directory, 'dist', 'service.js'), 'utf8')).toBe( + 'export class S {}\n\n//# sourceMappingURL=service.js.map', + ); + expect( + readFileSync(join(directory, 'dist', 'service.js.map'), 'utf8'), + ).toContain('"sources":["src/service.ts"]'); + expect( + readFileSync(join(directory, 'dist', 'service.d.ts'), 'utf8'), + ).toBe( + 'export declare class S {}\n\n//# sourceMappingURL=service.d.ts.map', + ); + expect( + readFileSync(join(directory, 'dist', 'service.d.ts.map'), 'utf8'), + ).toContain('"sources":["src/service.ts"]'); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should inline source maps when inlineSourceMap is configured', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync(join(directory, 'src', 'main.ts'), 'const value = 1;'); + process.chdir(directory); + + compiler['loadOxcTransform'] = jest.fn().mockResolvedValue({ + transform: jest.fn().mockResolvedValue({ + code: 'const value = 1;\n', + map: { + version: 3, + sources: ['src/main.ts'], + names: [], + mappings: '', + }, + errors: [], + }), + }); + + const options = actualOxcDefaultsFactory( + { + rootDir: 'src', + inlineSourceMap: true, + }, + { sourceRoot: 'src' } as any, + ); + + await compiler['transpileFile']('src/main.ts', options); + + const output = readFileSync(join(directory, 'dist', 'main.js'), 'utf8'); + expect(output).toContain( + '//# sourceMappingURL=data:application/json;charset=utf-8;base64,', + ); + expect(existsSync(join(directory, 'dist', 'main.js.map'))).toBe(false); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should preserve module-specific output extensions', () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + process.chdir(directory); + const options = actualOxcDefaultsFactory({ rootDir: 'src' }, { + sourceRoot: 'src', + } as any); + + expect(compiler['getOutputPath']('src/module.mts', options, '.js')).toBe( + join(directory, 'dist', 'module.mjs'), + ); + expect(compiler['getOutputPath']('src/module.cts', options, '.js')).toBe( + join(directory, 'dist', 'module.cjs'), + ); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should ignore declaration files and unsupported extensions', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync(join(directory, 'src', 'main.ts'), 'const value = 1;'); + writeFileSync( + join(directory, 'src', 'types.d.ts'), + 'declare const x: 1;', + ); + writeFileSync(join(directory, 'src', 'schema.graphql'), 'type Query'); + process.chdir(directory); + + const options = actualOxcDefaultsFactory({}, { + sourceRoot: 'src', + } as any); + + await expect(compiler['getFilesToCompile'](options)).resolves.toEqual([ + 'src/main.ts', + ]); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should pass Nest decorator metadata options to OXC', async () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'src')); + writeFileSync( + join(directory, 'src', 'service.ts'), + ` +function Injectable(): ClassDecorator { + return () => undefined; +} + +class Dependency {} + +@Injectable() +export class Service { + constructor(private readonly dependency: Dependency) {} +} +`, + ); + process.chdir(directory); + + const transform = jest.fn().mockResolvedValue({ + code: 'transformed service', + errors: [], + }); + compiler['loadOxcTransform'] = jest.fn().mockResolvedValue({ transform }); + + const options = actualOxcDefaultsFactory( + { + rootDir: 'src', + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + { sourceRoot: 'src' } as any, + ); + + await compiler['transpileFile']('src/service.ts', options); + + expect(transform).toHaveBeenCalledWith( + 'src/service.ts', + expect.stringContaining('@Injectable()'), + expect.objectContaining({ + decorator: { + legacy: true, + emitDecoratorMetadata: true, + }, + typescript: expect.objectContaining({ + allowNamespaces: true, + }), + }), + ); + expect(readFileSync(join(directory, 'dist', 'service.js'), 'utf8')).toBe( + 'transformed service', + ); + + rmSync(directory, { recursive: true, force: true }); + }); + + it('should remove compiled outputs for deleted source files', () => { + const directory = mkdtempSync(join(tmpdir(), 'nest-cli-oxc-')); + mkdirSync(join(directory, 'dist'), { recursive: true }); + writeFileSync(join(directory, 'dist', 'main.js'), ''); + writeFileSync(join(directory, 'dist', 'main.js.map'), ''); + writeFileSync(join(directory, 'dist', 'main.d.ts'), ''); + writeFileSync(join(directory, 'dist', 'main.d.ts.map'), ''); + process.chdir(directory); + + const options = actualOxcDefaultsFactory({ rootDir: 'src' }, { + sourceRoot: 'src', + } as any); + + compiler['removeCompiledFile']('src/main.ts', options); + + expect(existsSync(join(directory, 'dist', 'main.js'))).toBe(false); + expect(existsSync(join(directory, 'dist', 'main.js.map'))).toBe(false); + expect(existsSync(join(directory, 'dist', 'main.d.ts'))).toBe(false); + expect(existsSync(join(directory, 'dist', 'main.d.ts.map'))).toBe(false); + + rmSync(directory, { recursive: true, force: true }); + }); + }); +});