From 3478e0992394a186be3ed12e05917d30f6aaf573 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:26:49 -0500 Subject: [PATCH] [Dev] Break up `test:create` script; add help message, file name CLI argument support (#6793) * [Dev] Broke up `test:create` script, added CLI args file name suppoert * Moved `HELP_FLAGS` constant; fixed help msg indentation * ran biome * Fix floting promise err * Added REUSE info * Typo fix * comment out reward boilerplate * Removed redundant comments --------- Co-authored-by: fabske0 <192151969+fabske0@users.noreply.github.com> --- biome.jsonc | 10 ++ scripts/create-test/cli.js | 80 +++++++++ scripts/create-test/constants.js | 47 ++++++ scripts/create-test/create-test.js | 235 ++++++--------------------- scripts/create-test/dirs.js | 56 +++++++ scripts/create-test/help-message.js | 32 ++++ scripts/create-test/interactive.js | 66 ++++++++ scripts/helpers/file.js | 36 ++++ scripts/parse-egg-moves/main.js | 2 +- scripts/scrape-trainer-names/main.js | 2 +- 10 files changed, 378 insertions(+), 188 deletions(-) create mode 100644 scripts/create-test/cli.js create mode 100644 scripts/create-test/constants.js create mode 100644 scripts/create-test/dirs.js create mode 100644 scripts/create-test/help-message.js create mode 100644 scripts/create-test/interactive.js create mode 100644 scripts/helpers/file.js diff --git a/biome.jsonc b/biome.jsonc index 27ce10b8629..3a2c39e1541 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -251,6 +251,16 @@ } }, "overrides": [ + { + "includes": ["**/scripts/**/*.js"], + "linter": { + "rules": { + "nursery": { + "noFloatingPromises": "error" + } + } + } + }, { "includes": ["**/test/**/*.test.ts"], "linter": { diff --git a/scripts/create-test/cli.js b/scripts/create-test/cli.js new file mode 100644 index 00000000000..2bf24d78470 --- /dev/null +++ b/scripts/create-test/cli.js @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2024-2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import chalk from "chalk"; +import { cliAliases, validTestTypes } from "./constants.js"; +import { promptFileName, promptTestType } from "./interactive.js"; + +/** + * @import {testType} from "./constants.js" + */ + +/** + * Parse `process.argv` to retrieve the test type if it exists, otherwise prompting input from the user. + * @param {string | undefined} arg - The argument from `process.argv` + * @returns {Promise} + * A Promise that resolves with the type of test to be created, or `undefined` if the user interactively selects "Exit". + * Will set `process.exitCode` to a non-zero integer if args are invalid. + */ +export async function getTestType(arg) { + if (arg == null) { + return await promptTestType(); + } + + const testType = getCliTestType(arg); + if (testType) { + console.log(chalk.blue(`Using ${testType} as test type from CLI...`)); + return testType; + } + console.error( + chalk.red.bold( + `โœ— Invalid type of test file specified: ${arg}!\nValid types: ${chalk.blue(validTestTypes.join(", "))}`, + ), + ); + process.exitCode = 1; + return; +} + +/** + * Parse a test type from command-line args. + * @param {string} arg + * @returns {testType | undefined} The resulting test type. + * Will return `undefined` if no valid match was found. + */ +function getCliTestType(arg) { + // Check for a direct match, falling back to alias checking if none work + const testTypeName = validTestTypes.find(c => c.toLowerCase() === arg.toLowerCase()); + if (testTypeName) { + return testTypeName; + } + + const alias = /** @type {(keyof typeof cliAliases)[]} */ (Object.keys(cliAliases)).find(aliasKey => + cliAliases[aliasKey].some(alias => alias.toLowerCase() === arg.toLowerCase()), + ); + return alias; +} + +/** + * Obtain the file name for a given file + * @param {testType} testType - The chosen test type + * @param {string | undefined} arg - The contents of `process.argv[3]`, if it exists + * @returns {Promise} A promise that resolves with the name of the file to create. + */ +export async function getFileName(testType, arg) { + if (arg == null) { + return await promptFileName(testType); + } + + const nameTrimmed = arg.trim().replace(".test.ts", ""); + if (nameTrimmed.length === 0) { + console.error(chalk.red.bold("โœ— Cannot use an empty string as a file name!")); + process.exitCode = 1; + return; + } + + console.log(chalk.blue(`Using ${nameTrimmed} as file name from CLI...`)); + return nameTrimmed; +} diff --git a/scripts/create-test/constants.js b/scripts/create-test/constants.js new file mode 100644 index 00000000000..e3ddfaa7a24 --- /dev/null +++ b/scripts/create-test/constants.js @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024-2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Array containing all valid options for the type of test file to create. + * @package + */ +export const validTestTypes = /** @type {const} */ ([ + "Move", + "Ability", + "Item", + "Reward", + "Mystery Encounter", + "Utils", + "UI", +]); + +/** + * @typedef {typeof validTestTypes[number]} + * testType + * Union type representing a single valid choice of test type. + */ + +/** + * Const object mapping each test type to any additional names they can be used with from CLI. + * @satisfies {Partial>} + */ +export const cliAliases = /** @type {const} */ ({ + "Mystery Encounter": ["ME"], +}); + +/** + * Const object matching all test types to the directories in which their tests reside. + * @satisfies {Record} + */ +export const testTypesToDirs = /** @type {const} */ ({ + Move: "moves", + Ability: "abilities", + Item: "items", + Reward: "rewards", + "Mystery Encounter": "mystery-encounter/encounters", + Utils: "utils", + UI: "ui", +}); diff --git a/scripts/create-test/create-test.js b/scripts/create-test/create-test.js index ad1f999177c..3c49ceb3621 100644 --- a/scripts/create-test/create-test.js +++ b/scripts/create-test/create-test.js @@ -11,146 +11,25 @@ */ import fs from "node:fs"; -import path, { join } from "node:path"; +import { join } from "node:path"; import chalk from "chalk"; -import inquirer from "inquirer"; +import { writeFileSafe } from "../helpers/file.js"; +import { toKebabCase, toTitleCase } from "../helpers/strings.js"; +import { getFileName, getTestType } from "./cli.js"; +import { getBoilerplatePath, getTestFileFullPath } from "./dirs.js"; +import { HELP_FLAGS, showHelpText } from "./help-message.js"; + +/** + * @import {testType} from "./constants.js" + */ //#region Constants - -const version = "2.0.2"; -// Get the directory name of the current module file +const version = "2.1.0"; const __dirname = import.meta.dirname; -const projectRoot = path.join(__dirname, "..", ".."); - -const choices = /** @type {const} */ (["Move", "Ability", "Item", "Reward", "Mystery Encounter", "Utils", "UI"]); -/** @typedef {typeof choices[number]} choiceType */ -/** - * Object mapping choice types to extra names they can be used with from CLI. - * @satisfies {Partial>} - */ -const cliAliases = { - "Mystery Encounter": ["ME"], -}; - -/** @satisfies {{[k in choiceType]: string}} */ -const choicesToDirs = /** @type {const} */ ({ - Move: "moves", - Ability: "abilities", - Item: "items", - Reward: "rewards", - "Mystery Encounter": "mystery-encounter/encounters", - Utils: "utils", - UI: "ui", -}); - +const projectRoot = join(__dirname, "..", ".."); //#endregion -//#region Functions -/** - * Get the path to a given folder in the test directory - * @param {...string} folders the subfolders to append to the base path - * @returns {string} the path to the requested folder - */ -function getTestFolderPath(...folders) { - return path.join(projectRoot, "test", ...folders); -} - -/** - * Prompts the user to select a type via list. - * @returns {Promise} the selected type - */ -async function promptTestType() { - /** @type {choiceType | "EXIT"} */ - const choice = ( - await inquirer.prompt([ - { - type: "list", - name: "selectedOption", - message: "What type of test would you like to create?", - choices: [...choices, "EXIT"], - }, - ]) - ).selectedOption; - - if (choice === "EXIT") { - console.log("Exiting..."); - return process.exit(0); - } - - return choice; -} - -/** - * Prompts the user to provide a file name. - * @param {choiceType} selectedType The chosen string (used to display console logs) - * @returns {Promise} the selected file name - */ -async function promptFileName(selectedType) { - /** @type {string} */ - const fileNameAnswer = ( - await inquirer.prompt([ - { - type: "input", - name: "userInput", - message: `Please provide the name of the ${selectedType}.`, - }, - ]) - ).userInput; - - if (fileNameAnswer.trim().length === 0) { - console.error("Please provide a valid file name!"); - return await promptFileName(selectedType); - } - - return fileNameAnswer; -} - -/** - * Obtain the path to the boilerplate file based on the current option. - * @param {choiceType} choiceType The choice selected - * @returns {string} The path to the boilerplate file - */ -function getBoilerplatePath(choiceType) { - switch (choiceType) { - // case "Reward": - // return path.join(__dirname, "boilerplates/reward.boilerplate.ts"); - default: - return path.join(__dirname, "boilerplates/default.boilerplate.ts"); - } -} - -/** - * Parse `process.argv` and get the test type if it exists. - * @returns {choiceType | undefined} - * The type of choice the CLI args corresponds to, or `undefined` if none were specified. - * Will set `process.exitCode` to a non-zero integer if args are invalid. - */ -function convertArgsToTestType() { - // If the first argument is a test name, use that as the test name - const args = process.argv.slice(2); - if (args[0] == null) { - return; - } - - // Check for a direct match, falling back to alias checking. - const choiceName = choices.find(c => c.toLowerCase() === args[0].toLowerCase()); - if (choiceName) { - return choiceName; - } - - const alias = /** @type {(keyof cliAliases)[]} */ (Object.keys(cliAliases)).find(k => - cliAliases[k].some(a => a.toLowerCase() === args[0].toLowerCase()), - ); - if (alias) { - return alias; - } - console.error( - chalk.red.bold(`โœ— Invalid type of test file specified: ${args[0]}! -Valid types: ${chalk.blue(choices.join(", "))}`), - ); - process.exitCode = 1; - return; -} +//#region Main /** * Run the interactive `test:create` CLI. @@ -159,65 +38,49 @@ Valid types: ${chalk.blue(choices.join(", "))}`), async function runInteractive() { console.group(chalk.grey(`๐Ÿงช Create Test - v${version}\n`)); - const cliTestType = convertArgsToTestType(); - if (process.exitCode) { + const args = process.argv.slice(2); + + if (HELP_FLAGS.some(h => args.includes(h))) { + return showHelpText(); + } + + const testType = await getTestType(args[0]); + if (process.exitCode || !testType) { return; } - // TODO: Add a help command + + const fileNameAnswer = await getFileName(testType, args[1]); + if (process.exitCode || !fileNameAnswer) { + return; + } + try { - let choice; - if (cliTestType) { - console.log(chalk.blue(`Using ${cliTestType} as test type from CLI...`)); - choice = cliTestType; - } else { - choice = await promptTestType(); - } - const fileNameAnswer = await promptFileName(choice); - - // Convert fileName from snake_case or camelCase to kebab-case - const fileName = fileNameAnswer - .replace(/_+/g, "-") // Convert snake_case (underscore) to kebab-case (dashes) - .replace(/([a-z])([A-Z])/g, "$1-$2") // Convert camelCase to kebab-case - .replace(/\s+/g, "-") // Replace spaces with dashes - .toLowerCase(); // Ensure all lowercase - - // Format the description for the test case in Title Case - const formattedName = fileName.replace(/-/g, " ").replace(/\b\w/g, char => char.toUpperCase()); - const description = `${choice} - ${formattedName}`; - - // Determine the directory based on the type - const localDir = choicesToDirs[choice]; - const absoluteDir = getTestFolderPath(localDir); - - // Define the content template - const content = fs.readFileSync(getBoilerplatePath(choice), "utf8").replace("{{description}}", description); - - // Ensure the directory exists - if (!fs.existsSync(absoluteDir)) { - fs.mkdirSync(absoluteDir, { recursive: true }); - } - - // Create the file with the given name - const filePath = path.join(absoluteDir, `${fileName}.test.ts`); - - if (fs.existsSync(filePath)) { - console.error(chalk.red.bold(`โœ— File "${fileName}.test.ts" already exists!\n`)); - process.exit(1); - } - - // Write the template content to the file - fs.writeFileSync(filePath, content, "utf8"); - - console.log(chalk.green.bold(`โœ” File created at: ${join("test", localDir, fileName)}.test.ts\n`)); - console.groupEnd(); + doCreateFile(testType, fileNameAnswer); } catch (err) { console.error(chalk.red("โœ— Error: ", err)); } + console.groupEnd(); +} + +/** + * Helper function to create the test file. + * @param {testType} testType - The type of test to create + * @param {string} fileNameAnswer - The name of the file to create + * @returns {void} + */ +function doCreateFile(testType, fileNameAnswer) { + // Convert file name to kebab-case, formatting the description in Title Case + const fileName = toKebabCase(fileNameAnswer); + const formattedName = toTitleCase(fileNameAnswer); + const description = `${testType} - ${formattedName}`; + + const content = fs.readFileSync(getBoilerplatePath(testType), "utf8").replace("{{description}}", description); + const filePath = getTestFileFullPath(testType, fileName); + writeFileSafe(filePath, content, "utf8"); + + console.log(chalk.green.bold(`โœ” File created at: ${filePath.replace(`${projectRoot}/`, "")}\n`)); } //#endregion -//#region Run -runInteractive(); - -//#endregion +await runInteractive(); diff --git a/scripts/create-test/dirs.js b/scripts/create-test/dirs.js new file mode 100644 index 00000000000..1254e2b94d5 --- /dev/null +++ b/scripts/create-test/dirs.js @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2024-2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { join } from "path"; +import { testTypesToDirs } from "./constants.js"; + +/** + * @import { testType } from "./constants.js"; + */ + +// Get the directory name of the current module file +const __dirname = import.meta.dirname; +const projectRoot = join(__dirname, "..", ".."); + +/** + * Const object matching all {@linkcode testType}s to any custom boilerplate files + * they may be associated with. + * @type {Readonly>>} + */ +const customBoilerplates = { + // Reward: "boilerplates/reward.boilerplate.ts", // Todo: Boilerplate is added in the modifier rework +}; + +const DEFAULT_BOILERPLATE_PATH = "boilerplates/default.boilerplate.ts"; + +/** + * Retrieve the path to the boilerplate file used for the given test type. + * @param {testType} testType - The type of test file to create + * @returns {string} The path to the boilerplate file. + */ +export function getBoilerplatePath(testType) { + return join(import.meta.dirname, customBoilerplates[testType] ?? DEFAULT_BOILERPLATE_PATH); +} + +/** + * Get the path to a given folder in the test directory + * @param {...string} folders the subfolders to append to the base path + * @returns {string} the path to the requested folder + */ +function getTestFolderPath(...folders) { + return join(projectRoot, "test", ...folders); +} + +/** + * Helper function to convert the test file name into an absolute path. + * @param {testType} testType - The type of test being created (used to look up folder) + * @param {string} fileName - The name of the test file (without suffix) + * @returns {string} + */ +export function getTestFileFullPath(testType, fileName) { + const absoluteDir = getTestFolderPath(testTypesToDirs[testType]); + return join(absoluteDir, `${fileName}.test.ts`); +} diff --git a/scripts/create-test/help-message.js b/scripts/create-test/help-message.js new file mode 100644 index 00000000000..e6589b40d89 --- /dev/null +++ b/scripts/create-test/help-message.js @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025 Pagefault Games + * SPDX-FileContributor: Bertie690 + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import chalk from "chalk"; + +/** + * Array containing all valid ways of showing the help message. + */ +export const HELP_FLAGS = /** @type {const} */ (["-h", "-help", "--help"]); + +/** + * Show help/usage text for the `test:create` CLI. + * @package + */ +export function showHelpText() { + console.log(` +Usage: ${chalk.cyan("pnpm test:create [options] [testType] [fileName]")} +If either ${chalk.hex("#7fff00")("testType")} or ${chalk.hex("#7fff00")("fileName")} are omitted, +they will be selected interactively. + +${chalk.hex("#8a2be2")("Arguments:")} + ${chalk.hex("#7fff00")("testType")} The type/category of test file to create. + ${chalk.hex("#7fff00")("fileName")} The name of the test file to create. + +${chalk.hex("#ffa500")("Options:")} + ${chalk.blue("-h, -help, --help")} Show this help message. +`); +} diff --git a/scripts/create-test/interactive.js b/scripts/create-test/interactive.js new file mode 100644 index 00000000000..622e823e9cd --- /dev/null +++ b/scripts/create-test/interactive.js @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2024-2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import chalk from "chalk"; +import inquirer from "inquirer"; +import { validTestTypes } from "./constants.js"; + +/** + * @import {testType} from "./constants.js" + */ +/** + * Prompt the user to select a test type via list. + * @returns {Promise} The selected type, or `undefined` if "Exit" was pressed. + */ +export async function promptTestType() { + /** @type {testType | "EXIT"} */ + const choice = ( + await inquirer.prompt([ + { + type: "list", + name: "selectedOption", + message: "What type of test would you like to create?", + choices: [...validTestTypes, "EXIT"], + }, + ]) + ).selectedOption; + + if (choice === "EXIT") { + console.log("Exiting..."); + process.exitCode = 0; + return; + } + + return choice; +} + +/** + * Prompt the user to provide a file name. + * @param {testType} selectedType - The chosen string (used for logging & validation) + * @returns {Promise} The selected file name + */ +export async function promptFileName(selectedType) { + /** @type {string} */ + const fileNameAnswer = ( + await inquirer.prompt([ + { + type: "input", + name: "userInput", + message: `Please provide the name of the ${selectedType}.`, + validate: name => { + const nameProcessed = name.trim().replace(".test.ts", ""); + if (nameProcessed.length === 0) { + return chalk.red.bold("โœ— Cannot use an empty string as a file name!"); + } + return true; + }, + }, + ]) + ).userInput; + + // Trim whitespace and any extension suffixes + return fileNameAnswer.trim().replace(".test.ts", ""); +} diff --git a/scripts/helpers/file.js b/scripts/helpers/file.js new file mode 100644 index 00000000000..bd4ad5b9dd7 --- /dev/null +++ b/scripts/helpers/file.js @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2025 Pagefault Games + * SPDX-FileContributor: Bertie690 + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * @import {PathOrFileDescriptor, WriteFileOptions} from "node:fs" + */ + +/** + * "Safely" write to a file, creating any parent directories as required. + * @param {PathOrFileDescriptor} file - The filename or file descriptor to open + * @param {string | NodeJS.ArrayBufferView} content - The content which will be written + * @param {WriteFileOptions} [options] + * @returns {void} + * @remarks + * If `file` is a file descriptor, this method will simply return the result of + * {@linkcode writeFileSync} verbatim. + */ +export function writeFileSafe(file, content, options) { + if (typeof file === "number") { + return writeFileSync(file, content, options); + } + + const parentDir = dirname(file.toString("utf-8")); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + writeFileSync(file, content, options); +} diff --git a/scripts/parse-egg-moves/main.js b/scripts/parse-egg-moves/main.js index e7b3af53fde..dd4fafb45cb 100644 --- a/scripts/parse-egg-moves/main.js +++ b/scripts/parse-egg-moves/main.js @@ -170,4 +170,4 @@ function badArgs() { process.exitCode = 1; } -start(); +await start(); diff --git a/scripts/scrape-trainer-names/main.js b/scripts/scrape-trainer-names/main.js index f0424ae5392..bb5f48c62c3 100644 --- a/scripts/scrape-trainer-names/main.js +++ b/scripts/scrape-trainer-names/main.js @@ -297,4 +297,4 @@ async function promptExisting(outFile) { ).continue; } -main(); +await main();