diff --git a/.c8rc.json b/.c8rc.json index 873b88cb39e..340d0f559aa 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -1,8 +1,9 @@ { "all": true, - "clean": false, + "clean": true, "root": ".", "include": ["packages/*/src/**/*.ts"], + "exclude": ["packages/*/src/**/types.ts"], "reporter": ["text", "html", "lcov"], "exclude-after-remap": true } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 87b1056f8ca..3de8429e949 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -95,7 +95,6 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} - verbose: true smoketests: name: Smoketests @@ -131,3 +130,8 @@ jobs: - name: Run smoketests run: npm run test:smoketests + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index ef386f5e81c..db588310de6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # Node dependencies node_modules test/**/node_modules +!test/external-command/node_modules # Lock files yarn.lock diff --git a/package.json b/package.json index 67559667c1a..b71788967ed 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "./packages/*" ], "scripts": { - "clean": "del-cli \"*.tsbuildinfo\" \"packages/**/*.tsbuildinfo\" \"packages/*/lib/!(*.tpl)\" \"**/.yo-rc.json\"", + "clean": "del-cli \"*.tsbuildinfo\" \"packages/**/*.tsbuildinfo\" \"packages/*/lib/!(*.tpl)\"", "prebuild": "npm run clean", "prebuild:ci": "npm run clean && node ./scripts/setup-build.js", "build": "tsc --build", @@ -38,8 +38,8 @@ "fix:code": "npm run lint:code -- --fix", "pretest": "npm run build && npm run lint", "test": "npm run test:base", - "test:base": "node --experimental-vm-modules --enable-source-maps ./node_modules/jest-cli/bin/jest", - "test:smoketests": "c8 node smoketests", + "test:base": "node --experimental-vm-modules --enable-source-maps ./node_modules/jest-cli/bin/jest --workerThreads", + "test:smoketests": "c8 node --enable-source-maps smoketests/index.js", "test:coverage": "c8 npm run test:base", "update:docs": "node ./scripts/update-docs", "version": "node ./node_modules/.bin/changeset version && node ./scripts/sync-changelogs.js", diff --git a/packages/create-webpack-app/src/utils/generate-files.ts b/packages/create-webpack-app/src/utils/generate-files.ts index 2eb75fdef3b..b0182decbc3 100644 --- a/packages/create-webpack-app/src/utils/generate-files.ts +++ b/packages/create-webpack-app/src/utils/generate-files.ts @@ -162,10 +162,6 @@ async function checkAndPrepareContent(config: AddConfig, isTemplate: boolean): P return { status: "identical", content: existingFileContent }; } - // Prompt for conflict resolution - const tempFilePath = path.join("/tmp", `temp_${path.basename(config.path)}`); - await fs.writeFile(tempFilePath, newContent || ""); - let userChoice: Result | undefined; while (!userChoice) { const action = await expand({ @@ -194,7 +190,14 @@ async function checkAndPrepareContent(config: AddConfig, isTemplate: boolean): P case "overwrite_all": globalConfig.overwriteAll = true; return { status: "overwrite", content: newContent }; - case "diff": + case "diff": { + // Prompt for conflict resolution + const tempFilePath = path.join( + config.data!.projectPath as string, + `.temp_${path.basename(config.path)}`, + ); + await fs.writeFile(tempFilePath, newContent || ""); + if (!isTemplate && Buffer.isBuffer(existingFileContent)) { const existingStats = await fs.stat(config.path); const newStats = await fs.stat(tempFilePath); @@ -219,17 +222,18 @@ async function checkAndPrepareContent(config: AddConfig, isTemplate: boolean): P } else { await getDiff(config.path, tempFilePath); } + + await fs.unlink(tempFilePath).catch(() => { + logger.warn(`Failed to delete temporary file: ${tempFilePath}`); + }); break; + } case "abort": logger.error("Aborting process..."); process.exit(1); } } - await fs.unlink(tempFilePath).catch(() => { - logger.warn(`Failed to delete temporary file: ${tempFilePath}`); - }); - return userChoice; } // If the file doesn't exist, create it diff --git a/packages/create-webpack-app/tsconfig.json b/packages/create-webpack-app/tsconfig.json index e8d69d9dc5f..488b493e472 100644 --- a/packages/create-webpack-app/tsconfig.json +++ b/packages/create-webpack-app/tsconfig.json @@ -1,14 +1,8 @@ { "extends": "../../tsconfig.json", - "exclude": ["src/utils/__tests__"], "compilerOptions": { "outDir": "lib", "rootDir": "src" }, - "include": ["./src"], - "references": [ - { - "path": "../webpack-cli" - } - ] + "include": ["./src"] } diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index a2605991542..dd88847a165 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -100,7 +100,6 @@ interface CommandOptions< usage?: string; dependencies?: string[]; pkg?: string; - external?: boolean; preload?: () => Promise; options?: | CommandOption[] @@ -543,13 +542,13 @@ class WebpackCLI { async makeCommand( options: CommandOptions, - ): Promise { + ): Promise { const alreadyLoaded = this.program.commands.find( (command) => command.name() === options.rawName, ); if (alreadyLoaded) { - return; + return alreadyLoaded as Command; } const command = this.program.command(options.name, { @@ -777,11 +776,11 @@ class WebpackCLI { } if (mainOption.type.size > 1 && mainOption.type.has(Boolean)) { - mainOption.flags = `${mainOption.flags} [${mainOption.valueName || "value"}${ + mainOption.flags = `${mainOption.flags} [${mainOption.valueName}${ mainOption.multiple ? "..." : "" }]`; } else if (mainOption.type.size > 0 && !mainOption.type.has(Boolean)) { - mainOption.flags = `${mainOption.flags} <${mainOption.valueName || "value"}${ + mainOption.flags = `${mainOption.flags} <${mainOption.valueName}${ mainOption.multiple ? "..." : "" }>`; } @@ -840,10 +839,6 @@ class WebpackCLI { optionForCommand.hidden = option.hidden || false; - if (option.configs) { - (optionForCommand as Option & { configs: ArgumentConfig[] }).configs = option.configs; - } - command.addOption(optionForCommand); } } else if (mainOption.type.size > 1) { @@ -883,7 +878,8 @@ class WebpackCLI { const optionForCommand = new Option(mainOption.flags, mainOption.description); // Hide stub option - optionForCommand.hidden = option.hidden || false; + // TODO find a solution to hide such options in the new commander version, for example `--performance` and `--no-performance` because we don't have `--performance` at all + optionForCommand.hidden = option.hidden || true; (optionForCommand as Option & { internal?: boolean }).internal = true; command.addOption(optionForCommand); @@ -1163,22 +1159,11 @@ class WebpackCLI { } else { const [name] = options; - await this.#loadCommandByName(name); - - const command = this.#findCommandByName(name); + const command = await this.#loadCommandByName(name); if (!command) { - const builtInCommandUsed = Object.values(this.#commands).find( - (command) => command.name.includes(name) || name === command.alias, - ); - if (typeof builtInCommandUsed !== "undefined") { - this.logger.error( - `For using '${name}' command you need to install '${builtInCommandUsed.pkg}' package.`, - ); - } else { - this.logger.error(`Can't find and load command '${name}'`); - this.logger.error("Run 'webpack --help' to see available commands and options."); - } + this.logger.error(`Can't find and load command '${name}'`); + this.logger.error("Run 'webpack --help' to see available commands and options."); process.exit(2); } @@ -1202,9 +1187,9 @@ class WebpackCLI { outputIncorrectUsageOfHelp(); } - await this.#loadCommandByName(commandName); - - const command = isGlobalOption(optionName) ? program : this.#findCommandByName(commandName); + const command = isGlobalOption(optionName) + ? program + : await this.#loadCommandByName(commandName); if (!command) { this.logger.error(`Can't find and load command '${commandName}'`); @@ -1246,10 +1231,6 @@ class WebpackCLI { this.logger.raw(`${bold("Description:")} ${option.description}`); } - if (!option.negate && option.defaultValue) { - this.logger.raw(`${bold("Default value:")} ${JSON.stringify(option.defaultValue)}`); - } - const { configs } = option as Option & { configs?: ArgumentConfig[] }; if (configs) { @@ -1916,81 +1897,58 @@ class WebpackCLI { ); } - async #loadCommandByName(commandName: string, allowToInstall = false) { + async #loadCommandByName(commandName: string): Promise { if (this.#isCommand(commandName, this.#commands.build)) { - await this.makeCommand(this.#commands.build); + return await this.makeCommand(this.#commands.build); } else if (this.#isCommand(commandName, this.#commands.serve)) { - await this.makeCommand(this.#commands.serve); + return await this.makeCommand(this.#commands.serve); } else if (this.#isCommand(commandName, this.#commands.watch)) { - await this.makeCommand(this.#commands.watch); + return await this.makeCommand(this.#commands.watch); } else if (this.#isCommand(commandName, this.#commands.help)) { // Stub for the `help` command - await this.makeCommand(this.#commands.help); + return await this.makeCommand(this.#commands.help); } else if (this.#isCommand(commandName, this.#commands.version)) { - await this.makeCommand(this.#commands.version); + return await this.makeCommand(this.#commands.version); } else if (this.#isCommand(commandName, this.#commands.info)) { - await this.makeCommand(this.#commands.info); + return await this.makeCommand(this.#commands.info); } else if (this.#isCommand(commandName, this.#commands.configtest)) { - await this.makeCommand(this.#commands.configtest); - } else { - const builtInExternalCommandInfo = Object.values(this.#commands) - .filter((item) => item.external) - .find( - (externalBuiltInCommandInfo) => - externalBuiltInCommandInfo.rawName === commandName || - (Array.isArray(externalBuiltInCommandInfo.alias) - ? externalBuiltInCommandInfo.alias.includes(commandName) - : externalBuiltInCommandInfo.alias === commandName), - ); - - let pkg: string; - - if (builtInExternalCommandInfo && builtInExternalCommandInfo.pkg) { - ({ pkg } = builtInExternalCommandInfo); - } else { - pkg = commandName; - } - - if (pkg !== "webpack-cli" && !(await this.isPackageInstalled(pkg))) { - if (!allowToInstall) { - return; - } - - pkg = await this.installPackage(pkg, { - preMessage: () => { - this.logger.error( - `For using this command you need to install: '${this.colors.green(pkg)}' package.`, - ); - }, - }); - } - - type Instantiable< - InstanceType = unknown, - ConstructorParameters extends unknown[] = unknown[], - > = new (...args: ConstructorParameters) => InstanceType; - - let LoadedCommand: Instantiable<() => void>; + return await this.makeCommand(this.#commands.configtest); + } - try { - LoadedCommand = (await import(pkg)).default; - } catch { - // Ignore, command is not installed - return; - } + const pkg: string = commandName; - let command; + type Instantiable< + InstanceType = unknown, + ConstructorParameters extends unknown[] = unknown[], + > = new (...args: ConstructorParameters) => InstanceType & { apply(cli: WebpackCLI): Command }; - try { - command = new LoadedCommand(); + let LoadedCommand: Instantiable<() => void>; - await command.apply(this); - } catch (error) { + try { + LoadedCommand = (await import(pkg)).default; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ERR_MODULE_NOT_FOUND") { this.logger.error(`Unable to load '${pkg}' command`); this.logger.error(error); process.exit(2); } + + return; + } + + let command; + let externalCommand: Command; + + try { + command = new LoadedCommand(); + externalCommand = await command.apply(this); + } catch (error) { + this.logger.error(`Unable to load '${pkg}' command`); + this.logger.error(error); + process.exit(2); } + + return externalCommand; } async run(args: readonly string[], parseOptions: ParseOptions) { @@ -2123,25 +2081,25 @@ class WebpackCLI { process.exit(0); } - let commandNameToRun = operand; - let commandOperands = operands.slice(1); - let isKnownCommand = false; for (const command of Object.values(this.#commands)) { if ( - command.rawName === commandNameToRun || + command.rawName === operand || (Array.isArray(command.alias) - ? command.alias.includes(commandNameToRun) - : command.alias === commandNameToRun) + ? command.alias.includes(operand) + : command.alias === operand) ) { isKnownCommand = true; break; } } + let command: Command | undefined; + let commandOperands = operands.slice(1); + if (isKnownCommand) { - await this.#loadCommandByName(commandNameToRun, true); + command = await this.#loadCommandByName(operand); } else { let isEntrySyntax: boolean; @@ -2153,33 +2111,39 @@ class WebpackCLI { } if (isEntrySyntax) { - commandNameToRun = defaultCommandNameToRun; commandOperands = operands; - await this.#loadCommandByName(commandNameToRun); + command = await this.#loadCommandByName(defaultCommandNameToRun); } else { - this.logger.error(`Unknown command or entry '${operand}'`); + // Try to load external command + try { + command = await this.#loadCommandByName(operand); + } catch { + // Nothing + } - const found = Object.values(this.#commands).find( - (commandOptions) => distance(operand, commandOptions.rawName) < 3, - ); + if (!command) { + this.logger.error(`Unknown command or entry '${operand}'`); - if (found) { - this.logger.error( - `Did you mean '${found.rawName}' (alias '${Array.isArray(found.alias) ? found.alias.join(", ") : found.alias}')?`, + const found = Object.values(this.#commands).find( + (commandOptions) => distance(operand, commandOptions.rawName) < 3, ); - } - this.logger.error("Run 'webpack --help' to see available commands and options"); - process.exit(2); + if (found) { + this.logger.error( + `Did you mean '${found.rawName}' (alias '${Array.isArray(found.alias) ? found.alias.join(", ") : found.alias}')?`, + ); + } + + this.logger.error("Run 'webpack --help' to see available commands and options"); + process.exit(2); + } } } - const command = this.#findCommandByName(commandNameToRun); - if (!command) { throw new Error( - `Internal error: Registered command "${commandNameToRun}" is missing an action handler.`, + `Internal error: Registered command "${operand}" is missing an action handler.`, ); } diff --git a/smoketests/helpers.js b/smoketests/helpers.js index 546d31ad7a6..3d410d2583c 100644 --- a/smoketests/helpers.js +++ b/smoketests/helpers.js @@ -30,6 +30,7 @@ const runTest = async (pkg, cliArgs = [], logMessage = undefined, isSubPackage = const proc = execa(CLI_ENTRY_PATH, cliArgs, { cwd: __dirname, reject: false, + gracefulCancel: true, cancelSignal: abortController.signal, }); @@ -92,6 +93,7 @@ const runTestStdout = async ({ packageName, cliArgs, logMessage, isSubPackage, c const proc = execa(CLI_ENTRY_PATH, cliArgs, { cwd: cwd || __dirname, reject: false, + gracefulCancel: true, cancelSignal: abortController.signal, }); @@ -150,6 +152,7 @@ const runTestStdoutWithInput = async ({ const proc = execa(CLI_ENTRY_PATH, cliArgs, { cwd: __dirname, reject: false, + gracefulCancel: true, cancelSignal: abortController.signal, }); @@ -207,6 +210,7 @@ const runTestWithHelp = async (pkg, cliArgs = [], logMessage = undefined, isSubP const proc = execa(CLI_ENTRY_PATH, cliArgs, { cwd: __dirname, reject: false, + gracefulCancel: true, cancelSignal: abortController.signal, }); diff --git a/test/build/core-flags/core-flags.test.js b/test/build/core-flags/core-flags.test.js index 890a520734e..596087d2ada 100644 --- a/test/build/core-flags/core-flags.test.js +++ b/test/build/core-flags/core-flags.test.js @@ -109,7 +109,6 @@ describe("core flags", () => { expect(exitCode).toBe(2); expect(stderr).toContain("Invalid value 'true' for the '--amd' option"); - expect(stderr).toContain("Expected: 'false'"); expect(stdout).toBeFalsy(); }); diff --git a/test/create-webpack-app/init/__snapshots__/init.test.js.snap.webpack5 b/test/create-webpack-app/init/__snapshots__/init.test.js.snap.webpack5 index 6b0418a44a9..463fd55904e 100644 --- a/test/create-webpack-app/init/__snapshots__/init.test.js.snap.webpack5 +++ b/test/create-webpack-app/init/__snapshots__/init.test.js.snap.webpack5 @@ -363,6 +363,170 @@ const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); const isProduction = process.env.NODE_ENV === 'production'; +/** @type {import("webpack").Configuration} */ +const config = { + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + }, + devServer: { + open: true, + host: 'localhost', + }, + plugins: [ + new HtmlWebpackPlugin({ + template: 'index.html', + }), + + // Add your plugins here + // Learn more about plugins from https://webpack.js.org/configuration/plugins/ + ], + module: { + rules: [ + { + test: /\\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, + type: 'asset', + }, + + { + test: /\\.html$/i, + use: ['html-loader'], + }, + + // Add your rules for custom modules here + // Learn more about loaders from https://webpack.js.org/loaders/ + ], + }, +}; + +module.exports = () => { + if (isProduction) { + config.mode = 'production'; + + + config.plugins.push(new WorkboxWebpackPlugin.GenerateSW()); + + } else { + config.mode = 'development'; + } + return config; +}; +" +`; + +exports[`create-webpack-app cli should generate default project when nothing is passed and handle conflicts 1`] = ` +{ + "description": "My webpack project", + "devDependencies": { + "html-loader": "x.x.x", + "html-webpack-plugin": "x.x.x", + "webpack": "x.x.x", + "webpack-cli": "x.x.x", + "webpack-dev-server": "x.x.x", + "workbox-webpack-plugin": "x.x.x", + }, + "name": "webpack-project", + "scripts": { + "build": "webpack --mode=production --config-node-env=production", + "build:dev": "webpack --mode=development", + "serve": "webpack serve", + "watch": "webpack --watch", + }, + "version": "1.0.0", +} +`; + +exports[`create-webpack-app cli should generate default project when nothing is passed and handle conflicts 2`] = ` +"// Generated using webpack-cli https://github.com/webpack/webpack-cli + +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); + +const isProduction = process.env.NODE_ENV === 'production'; + + +/** @type {import("webpack").Configuration} */ +const config = { + entry: './src/index.js', + output: { + path: path.resolve(__dirname, 'dist'), + }, + devServer: { + open: true, + host: 'localhost', + }, + plugins: [ + new HtmlWebpackPlugin({ + template: 'index.html', + }), + + // Add your plugins here + // Learn more about plugins from https://webpack.js.org/configuration/plugins/ + ], + module: { + rules: [ + { + test: /\\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, + type: 'asset', + }, + + { + test: /\\.html$/i, + use: ['html-loader'], + }, + + // Add your rules for custom modules here + // Learn more about loaders from https://webpack.js.org/loaders/ + ], + }, +}; + +module.exports = () => { + if (isProduction) { + config.mode = 'production'; + + + config.plugins.push(new WorkboxWebpackPlugin.GenerateSW()); + + } else { + config.mode = 'development'; + } + return config; +}; +" +`; + +exports[`create-webpack-app cli should generate default project when nothing is passed and handle conflicts 3`] = ` +{ + "description": "My webpack project", + "devDependencies": { + "@babel/core": "x.x.x", + "@babel/preset-env": "x.x.x", + "babel-loader": "x.x.x", + "webpack": "x.x.x", + "webpack-cli": "x.x.x", + }, + "name": "webpack-project", + "scripts": { + "build": "webpack --mode=production --config-node-env=production", + "build:dev": "webpack --mode=development", + "watch": "webpack --watch", + }, + "version": "1.0.0", +} +`; + +exports[`create-webpack-app cli should generate default project when nothing is passed and handle conflicts 4`] = ` +"// Generated using webpack-cli https://github.com/webpack/webpack-cli + +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); + +const isProduction = process.env.NODE_ENV === 'production'; + + /** @type {import("webpack").Configuration} */ const config = { entry: './src/index.js', diff --git a/test/create-webpack-app/init/init.test.js b/test/create-webpack-app/init/init.test.js index 94e405716c2..77711bcbfa1 100644 --- a/test/create-webpack-app/init/init.test.js +++ b/test/create-webpack-app/init/init.test.js @@ -117,6 +117,55 @@ describe("create-webpack-app cli", () => { expect(readFromWebpackConfig(dir)).toMatchSnapshot(); }); + it("should generate default project when nothing is passed and handle conflicts", async () => { + const { stdout } = await run(dir, ["init", "--force"]); + + expect(stdout).toContain("Project has been initialised with webpack!"); + expect(stdout).toContain("webpack.config.js"); + + // Test files + for (const file of defaultTemplateFiles) { + expect(existsSync(resolve(dir, file))).toBeTruthy(); + } + + // Check if the generated package.json file content matches the snapshot + expect(readFromPkgJSON(dir)).toMatchSnapshot(); + + // Check if the generated webpack configuration matches the snapshot + expect(readFromWebpackConfig(dir)).toMatchSnapshot(); + + const { stdout: nextStdout } = await runPromptWithAnswers( + dir, + ["init"], + [ + `${DOWN}${ENTER}`, + `n${ENTER}`, + `n${ENTER}`, + `n${ENTER}`, + `${UP}${ENTER}`, + ENTER, + // test for conflicts + `y${ENTER}`, + `n${ENTER}`, + `a${ENTER}`, + ], + ); + + expect(nextStdout).toContain("Project has been initialised with webpack!"); + expect(nextStdout).toContain("webpack.config.js"); + + // Test files + for (const file of defaultTemplateFiles) { + expect(existsSync(resolve(dir, file))).toBeTruthy(); + } + + // Check if the generated package.json file content matches the snapshot + expect(readFromPkgJSON(dir)).toMatchSnapshot(); + + // Check if the generated webpack configuration matches the snapshot + expect(readFromWebpackConfig(dir)).toMatchSnapshot(); + }); + it("should generate project when generationPath is supplied", async () => { const { stdout } = await run(__dirname, ["init", dir, "--force"]); diff --git a/test/create-webpack-app/loader/loader.test.js b/test/create-webpack-app/loader/loader.test.js index 6b4817af703..71f64aab484 100644 --- a/test/create-webpack-app/loader/loader.test.js +++ b/test/create-webpack-app/loader/loader.test.js @@ -163,13 +163,35 @@ describe("loader command", () => { it("should prompt on supplying an invalid template", async () => { const assetsPath = await uniqueDirectoryForTest(); - const { stderr } = await runPromptWithAnswers(assetsPath, [ - "loader", - ".", - "--template=unknown", - ]); + const { defaultLoaderPath, defaultTemplateFiles } = dataForTests(assetsPath); + let { stdout, stderr } = await runPromptWithAnswers( + assetsPath, + ["loader", ".", "--template=unknown"], + [ENTER, ENTER, ENTER], + ); expect(stderr).toContain("unknown is not a valid template"); + expect(normalizeStdout(stdout)).toContain(firstPrompt); + + // Skip test in case installation fails + if (!existsSync(resolve(defaultLoaderPath, "./package-lock.json"))) { + return; + } + + // Check if the output directory exists with the appropriate loader name + expect(existsSync(defaultLoaderPath)).toBeTruthy(); + + // All test files are scaffolded + for (const file of defaultTemplateFiles) { + expect(existsSync(resolve(defaultLoaderPath, file))).toBeTruthy(); + } + + // Check if the generated loader works successfully + const path = resolve(assetsPath, "./my-loader/examples/simple/"); + + ({ stdout } = await run(path, [])); + + expect(stdout).toContain("my-loader"); }); it("recognizes '-t' as an alias for '--template'", async () => { diff --git a/test/create-webpack-app/plugin/plugin.test.js b/test/create-webpack-app/plugin/plugin.test.js index 870f620fd1d..8e09f008ad4 100644 --- a/test/create-webpack-app/plugin/plugin.test.js +++ b/test/create-webpack-app/plugin/plugin.test.js @@ -164,9 +164,35 @@ describe("plugin command", () => { it("should prompt on supplying an invalid template", async () => { const assetsPath = await uniqueDirectoryForTest(); - const { stderr } = await runPromptWithAnswers(assetsPath, ["plugin", "--template=unknown"]); + const { defaultPluginPath, defaultTemplateFiles } = dataForTests(assetsPath); + let { stdout, stderr } = await runPromptWithAnswers( + assetsPath, + ["plugin", "--template=unknown"], + [ENTER, ENTER, ENTER], + ); expect(stderr).toContain("unknown is not a valid template"); + expect(normalizeStdout(stdout)).toContain(firstPrompt); + + // Check if the output directory exists with the appropriate plugin name + expect(existsSync(defaultPluginPath)).toBeTruthy(); + + // Skip test in case installation fails + if (!existsSync(resolve(defaultPluginPath, "./package-lock.json"))) { + return; + } + + // Test regressively files are scaffolded + for (const file of defaultTemplateFiles) { + expect(existsSync(join(defaultPluginPath, file))).toBeTruthy(); + } + + // Check if the generated plugin works successfully + ({ stdout } = await run(defaultPluginPath, [ + "--config", + "./examples/simple/webpack.config.js", + ])); + expect(normalizeStdout(stdout)).toContain("Hello World!"); }); it("recognizes '-t' as an alias for '--template'", async () => { diff --git a/test/external-command/external-command.test.js b/test/external-command/external-command.test.js new file mode 100644 index 00000000000..187814fad34 --- /dev/null +++ b/test/external-command/external-command.test.js @@ -0,0 +1,71 @@ +const { run } = require("../utils/test-utils"); + +describe("external command", () => { + it("should work", async () => { + const { exitCode, stdout, stderr } = await run(__dirname, ["custom-command"], { + nodeOptions: ["--import=./register-loader.mjs"], + }); + + expect(exitCode).toBe(0); + expect(stderr).toBeFalsy(); + expect(stdout).toContain("custom unknown"); + }); + + it("should work with options", async () => { + const { exitCode, stdout, stderr } = await run(__dirname, ["custom-command", "--output=json"], { + nodeOptions: ["--import=./register-loader.mjs"], + }); + + expect(exitCode).toBe(0); + expect(stderr).toBeFalsy(); + expect(stdout).toContain("custom json"); + }); + + it("should work with help", async () => { + const { exitCode, stdout, stderr } = await run(__dirname, ["help", "custom-command"], { + nodeOptions: ["--import=./register-loader.mjs"], + }); + + expect(exitCode).toBe(0); + expect(stderr).toBeFalsy(); + expect(stdout).toContain("Usage: webpack custom-command|cc [options]"); + expect(stdout).toContain("-o, --output To get the output in a specified format"); + }); + + it("should work with help for option", async () => { + const { exitCode, stdout, stderr } = await run( + __dirname, + ["help", "custom-command", "--output"], + { + nodeOptions: ["--import=./register-loader.mjs"], + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBeFalsy(); + expect(stdout).toContain("Usage: webpack custom-command --output "); + expect(stdout).toContain("Description: To get the output in a specified format"); + }); + + it("should handle errors in external commands", async () => { + const { exitCode, stdout, stderr } = await run(__dirname, ["errored-custom-command"], { + nodeOptions: ["--import=./register-loader.mjs"], + }); + + expect(exitCode).toBe(2); + expect(stderr).toContain("Unable to load 'errored-custom-command' command"); + expect(stderr).toContain("Error: error in apply"); + expect(stdout).toBeFalsy(); + }); + + it("should handle errors in external commands when loading", async () => { + const { exitCode, stdout, stderr } = await run(__dirname, ["errored-loading-custom-command"], { + nodeOptions: ["--import=./register-loader.mjs"], + }); + + expect(exitCode).toBe(2); + expect(stderr).toContain("Unable to load 'errored-loading-custom-command' command"); + expect(stderr).toContain("Error: error in loading"); + expect(stdout).toBeFalsy(); + }); +}); diff --git a/test/external-command/my-loader.mjs b/test/external-command/my-loader.mjs new file mode 100644 index 00000000000..e1d2f1665a0 --- /dev/null +++ b/test/external-command/my-loader.mjs @@ -0,0 +1,23 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code === "ERR_MODULE_NOT_FOUND" && !specifier.startsWith(".")) { + try { + const baseDir = join(process.cwd(), "node_modules/"); + const resolved = join(baseDir, specifier, "index.js"); + + return { + url: pathToFileURL(resolved).href, + shortCircuit: true, + }; + } catch { + throw err; + } + } + throw err; + } +} diff --git a/test/external-command/node_modules/custom-command/index.js b/test/external-command/node_modules/custom-command/index.js new file mode 100644 index 00000000000..e45eb4b4c9f --- /dev/null +++ b/test/external-command/node_modules/custom-command/index.js @@ -0,0 +1,29 @@ +export default class CustomCommand { + async apply(cli) { + const customCommand = await cli.makeCommand({ + rawName: "custom-command", + name: "custom-command", + alias: "cc", + usage: "[options]", + description: "Custom command.", + options: [ + { + name: "output", + alias: "o", + configs: [ + { + type: "string", + }, + ], + description: "To get the output in a specified format", + hidden: false, + }, + ], + action: async (options) => { + cli.logger.raw(`custom ${options.output ? options.output : "unknown"}`); + }, + }); + + return customCommand; + } +} diff --git a/test/external-command/node_modules/custom-command/package.json b/test/external-command/node_modules/custom-command/package.json new file mode 100644 index 00000000000..27b2af0afbf --- /dev/null +++ b/test/external-command/node_modules/custom-command/package.json @@ -0,0 +1,6 @@ +{ + "name": "custom-command", + "version": "1.0.0", + "description": "Custom command", + "type": "module" +} diff --git a/test/external-command/node_modules/errored-custom-command/index.js b/test/external-command/node_modules/errored-custom-command/index.js new file mode 100644 index 00000000000..b1fd5289c86 --- /dev/null +++ b/test/external-command/node_modules/errored-custom-command/index.js @@ -0,0 +1,5 @@ +export default class ErroredCustomCommand { + async apply(cli) { + throw new Error("error in apply"); + } +} diff --git a/test/external-command/node_modules/errored-custom-command/package.json b/test/external-command/node_modules/errored-custom-command/package.json new file mode 100644 index 00000000000..3d484b627a4 --- /dev/null +++ b/test/external-command/node_modules/errored-custom-command/package.json @@ -0,0 +1,6 @@ +{ + "name": "errored-custom-command", + "version": "1.0.0", + "description": "Custom command", + "type": "module" +} diff --git a/test/external-command/node_modules/errored-loading-custom-command/index.js b/test/external-command/node_modules/errored-loading-custom-command/index.js new file mode 100644 index 00000000000..de4bfd9477e --- /dev/null +++ b/test/external-command/node_modules/errored-loading-custom-command/index.js @@ -0,0 +1 @@ +throw new Error("error in loading"); diff --git a/test/external-command/node_modules/errored-loading-custom-command/package.json b/test/external-command/node_modules/errored-loading-custom-command/package.json new file mode 100644 index 00000000000..de56c9c736c --- /dev/null +++ b/test/external-command/node_modules/errored-loading-custom-command/package.json @@ -0,0 +1,6 @@ +{ + "name": "errored-loading-custom-command", + "version": "1.0.0", + "description": "Custom command", + "type": "module" +} diff --git a/test/external-command/register-loader.mjs b/test/external-command/register-loader.mjs new file mode 100644 index 00000000000..d3c8a88dce4 --- /dev/null +++ b/test/external-command/register-loader.mjs @@ -0,0 +1,4 @@ +import { register } from "node:module"; +import { pathToFileURL } from "node:url"; + +register("./my-loader.mjs", pathToFileURL("./")); diff --git a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 index 17e22931ffc..f15e7db852a 100644 --- a/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 +++ b/test/help/__snapshots__/help.test.js.snap.devServer5.webpack5 @@ -42,6 +42,11 @@ exports[`help should log error for invalid flag with the "--help" option #2: std exports[`help should log error for invalid flag with the "--help" option #3 1`] = `"[webpack-cli] Unknown value for '--help' option, please use '--help=verbose'"`; +exports[`help should log error for invalid flag with the "--help" option #4 1`] = ` +"[webpack-cli] Can't find and load command 'unknown' +[webpack-cli] Run 'webpack --help' to see available commands and options" +`; + exports[`help should log error for invalid flag with the "--help" option: stderr 1`] = ` "[webpack-cli] Incorrect use of help [webpack-cli] Please use: 'webpack help [command] [option]' | 'webpack [command] --help' diff --git a/test/help/help.test.js b/test/help/help.test.js index 56a32fe918e..282f8deba59 100644 --- a/test/help/help.test.js +++ b/test/help/help.test.js @@ -443,4 +443,12 @@ describe("help", () => { expect(stderr).toMatchSnapshot(); expect(stdout).toBeFalsy(); }); + + it('should log error for invalid flag with the "--help" option #4', async () => { + const { exitCode, stderr, stdout } = await run(__dirname, ["help", "unknown", "--unknown"]); + + expect(exitCode).toBe(2); + expect(stderr).toMatchSnapshot(); + expect(stdout).toBeFalsy(); + }); });