[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>
This commit is contained in:
Bertie690 2025-11-28 16:26:49 -05:00 committed by GitHub
parent 4522e9e593
commit 3478e09923
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 378 additions and 188 deletions

View File

@ -251,6 +251,16 @@
} }
}, },
"overrides": [ "overrides": [
{
"includes": ["**/scripts/**/*.js"],
"linter": {
"rules": {
"nursery": {
"noFloatingPromises": "error"
}
}
}
},
{ {
"includes": ["**/test/**/*.test.ts"], "includes": ["**/test/**/*.test.ts"],
"linter": { "linter": {

View File

@ -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<testType | undefined>}
* 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<string | undefined>} 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;
}

View File

@ -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<Record<testType, readonly string[]>>}
*/
export const cliAliases = /** @type {const} */ ({
"Mystery Encounter": ["ME"],
});
/**
* Const object matching all test types to the directories in which their tests reside.
* @satisfies {Record<testType, string>}
*/
export const testTypesToDirs = /** @type {const} */ ({
Move: "moves",
Ability: "abilities",
Item: "items",
Reward: "rewards",
"Mystery Encounter": "mystery-encounter/encounters",
Utils: "utils",
UI: "ui",
});

View File

@ -11,146 +11,25 @@
*/ */
import fs from "node:fs"; import fs from "node:fs";
import path, { join } from "node:path"; import { join } from "node:path";
import chalk from "chalk"; 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 //#region Constants
const version = "2.1.0";
const version = "2.0.2";
// Get the directory name of the current module file
const __dirname = import.meta.dirname; const __dirname = import.meta.dirname;
const projectRoot = path.join(__dirname, "..", ".."); const projectRoot = 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<Record<choiceType, readonly string[]>>}
*/
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",
});
//#endregion //#endregion
//#region Functions
/** //#region Main
* 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<choiceType>} 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<string>} 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;
}
/** /**
* Run the interactive `test:create` CLI. * Run the interactive `test:create` CLI.
@ -159,65 +38,49 @@ Valid types: ${chalk.blue(choices.join(", "))}`),
async function runInteractive() { async function runInteractive() {
console.group(chalk.grey(`🧪 Create Test - v${version}\n`)); console.group(chalk.grey(`🧪 Create Test - v${version}\n`));
const cliTestType = convertArgsToTestType(); const args = process.argv.slice(2);
if (process.exitCode) {
if (HELP_FLAGS.some(h => args.includes(h))) {
return showHelpText();
}
const testType = await getTestType(args[0]);
if (process.exitCode || !testType) {
return; return;
} }
// TODO: Add a help command
const fileNameAnswer = await getFileName(testType, args[1]);
if (process.exitCode || !fileNameAnswer) {
return;
}
try { try {
let choice; doCreateFile(testType, fileNameAnswer);
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();
} catch (err) { } catch (err) {
console.error(chalk.red("✗ Error: ", 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 //#endregion
//#region Run
runInteractive(); await runInteractive();
//#endregion

View File

@ -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<Partial<Record<testType, string>>>}
*/
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`);
}

View File

@ -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.
`);
}

View File

@ -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<testType | undefined>} 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<string>} 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", "");
}

36
scripts/helpers/file.js Normal file
View File

@ -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<ArrayBufferLike>} 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);
}

View File

@ -170,4 +170,4 @@ function badArgs() {
process.exitCode = 1; process.exitCode = 1;
} }
start(); await start();

View File

@ -297,4 +297,4 @@ async function promptExisting(outFile) {
).continue; ).continue;
} }
main(); await main();