diff --git a/.gitignore b/.gitignore index 96286eefad26..7fb07daf7f76 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +temp node_modules dist dist-ssr diff --git a/biome.jsonc b/biome.jsonc index 6f63a88eb3bd..982dabffff7b 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -3,7 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - { "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "vcs": { @@ -109,11 +108,15 @@ "level": "info", // TODO: Graduate to error eventually // NOTE: "checkAllProperties" has an immature implementation that // causes many false positives across files. Enable if/when maturity improves - "options": { "checkAllProperties": false } + "options": { + "checkAllProperties": false + } }, "useConsistentObjectDefinitions": { "level": "error", - "options": { "syntax": "shorthand" } + "options": { + "syntax": "shorthand" + } }, "useCollapsedIf": "error", "useCollapsedElseIf": "error", @@ -319,6 +322,19 @@ "includes": ["**/scripts/**/*.js"], "linter": { "rules": { + "style": { + // TODO: Remove this and replace it with an ambient module declaration once TSGo is capable of handling these + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "commander": { + "message": "Imports from the commander package lack the strict type safety of @commander-js/extra-typings, producing type errors when passed to relevant utility functions." + } + } + } + } + }, "nursery": { "noFloatingPromises": "error" } diff --git a/package.json b/package.json index b1a826de058f..215ee4ba37e2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:app": "vite build --mode app", "preview": "vite preview", "test": "vitest run", + "test:profile": "node scripts/profiling/profiling.js", "test:cov": "vitest run --silent='passed-only' --coverage", "test:watch": "vitest watch --silent='passed-only'", "test:silent": "vitest run --silent='passed-only'", @@ -42,6 +43,7 @@ "update-submodules:remote": "pnpm update-submodules --remote" }, "dependencies": { + "@commander-js/extra-typings": "^14.0.0", "@material/material-color-utilities": "^0.3.0", "ajv": "^8.18.0", "compare-versions": "^6.1.1", @@ -66,6 +68,7 @@ "@vitest/expect": "^4.0.18", "@vitest/utils": "^4.0.18", "chalk": "^5.6.2", + "commander": "^14.0.3", "dependency-cruiser": "^17.3.8", "jsdom": "^28.1.0", "lefthook": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be01f2b32402..1e79b2ce41c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0(commander@14.0.3) '@material/material-color-utilities': specifier: ^0.3.0 version: 0.3.0 @@ -81,6 +84,9 @@ importers: chalk: specifier: ^5.6.2 version: 5.6.2 + commander: + specifier: ^14.0.3 + version: 14.0.3 dependency-cruiser: specifier: ^17.3.8 version: 17.3.8 @@ -259,6 +265,11 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@commander-js/extra-typings@14.0.0': + resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} + peerDependencies: + commander: ~14.0.0 + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -2114,6 +2125,10 @@ snapshots: dependencies: css-tree: 3.1.0 + '@commander-js/extra-typings@14.0.0(commander@14.0.3)': + dependencies: + commander: 14.0.3 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': diff --git a/scripts/changelog-reader/config.mjs b/scripts/changelog-reader/config.mjs index 34e2213be070..1e8de76996ff 100644 --- a/scripts/changelog-reader/config.mjs +++ b/scripts/changelog-reader/config.mjs @@ -6,14 +6,15 @@ */ /** - * @typedef {LABELS[number]} Label + * @typedef {typeof LABELS[number]} Label */ /** * @typedef { "Bug Fixes" | "Balance" | "Translation" | "Art" | "Miscellaneous" | "Unknown" | "Beta" } CategoryName */ -/** @typedef {{ +/** + * @typedef {{ * name: CategoryName * labels: Label[] * }} Category diff --git a/scripts/helpers/arguments.js b/scripts/helpers/arguments.js index 615d98387b01..65b74458b98e 100644 --- a/scripts/helpers/arguments.js +++ b/scripts/helpers/arguments.js @@ -1,10 +1,14 @@ /* - * SPDX-FileCopyrightText: 2025 Pagefault Games + * SPDX-FileCopyrightText: 2025-2026 Pagefault Games * SPDX-FileContributor: Bertie690 * * SPDX-License-Identifier: AGPL-3.0-only */ +import chalk from "chalk"; + +/** @import {Help} from "@commander-js/extra-typings" */ + /** * Obtain the value of a property value CLI argument (one of the form * `-x=y` or `-x y`). @@ -14,6 +18,7 @@ * @remarks * This will mutate the `args` array by removing the first specified match. */ +// TODO: Remove and migrate existing scripts to `commander` (which does this for us) export function getPropertyValue(args, flags) { let /** @type {string | undefined} */ arg; // Extract the prop as either the form "-o=y" or "-o y". @@ -28,3 +33,76 @@ export function getPropertyValue(args, flags) { return arg; } + +/** Color for argument names in help text (`#7fff00`).*/ +export const ARGUMENT_COLOR = chalk.hex("#7fff00"); +/** Color for option flags in help text (`#8a2be2`). */ +export const OPTION_COLOR = chalk.hex("#8a2be2"); +/** Color for usage text in help text (`chalk.blue`). */ +export const USAGE_COLOR = chalk.blue; + +/** + * Standardized arguments to pass to Commander's help text formatter. + * @type {Partial} + * @remarks + * Extending this config is discouraged; its explicit purpose is to standardize a potential source of bikeshedding. + */ +export const defaultCommanderHelpArgs = { + styleUsage: str => USAGE_COLOR(str), + optionTerm: option => OPTION_COLOR(option.flags), + argumentTerm: argument => ARGUMENT_COLOR(argument.name()), + styleTitle: title => getTitleColor(title)(title), + optionDescription: option => { + // Inspired by the default reporter from `commander` source, but in Title Case to match existing conventions + /** @type {string[]} */ + const extraInfo = []; + + if (option.argChoices) { + extraInfo.push(`Choices: ${option.argChoices.map(choice => JSON.stringify(choice)).join(", ")}`); + } + if (option.defaultValue !== undefined) { + const showDefault = + option.required || option.optional || (option.isBoolean() && typeof option.defaultValue === "boolean"); + if (showDefault) { + extraInfo.push(`Default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`); + } + } + + if (option.presetArg !== undefined && option.optional) { + extraInfo.push(`Preset: ${JSON.stringify(option.presetArg)}`); + } + if (option.envVar !== undefined) { + extraInfo.push(`Env: ${option.envVar}`); + } + + if (extraInfo.length === 0) { + return option.description; + } + + const extraDescription = `(${extraInfo.join(", ")})`; + if (option.description) { + return `${option.description} ${extraDescription}`; + } + return extraDescription; + }, +}; + +/** + * Color title headers the same as their corresponding contents. + * @param {string} title - The title header being colored. + * @returns {(s: string) => string} + * A function to color the resulting output. + */ +function getTitleColor(title) { + const titleLower = title.toLowerCase(); + if (titleLower.includes("option")) { + return OPTION_COLOR.bold; + } + if (titleLower.includes("argument")) { + return ARGUMENT_COLOR.bold; + } + if (titleLower.includes("usage")) { + return USAGE_COLOR.bold; + } + return chalk.bold; +} diff --git a/scripts/helpers/process.js b/scripts/helpers/process.js new file mode 100644 index 000000000000..f7be7b023548 --- /dev/null +++ b/scripts/helpers/process.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Pagefault Games + * SPDX-FileContributor: Bertie690 + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { spawnSync } from "node:child_process"; + +/** + * Check if a given command exists in the system's `PATH`. + * Equivalent to the `which`/`where` commands on Unix/Windows. + * @param {string} command - The command to check + * @returns {boolean} Whether the command exists. + */ +export function commandExists(command) { + const cmd = process.platform === "win32" ? "where" : "which"; + return spawnSync(cmd, [command], { stdio: "ignore" }).status === 0; +} diff --git a/scripts/profiling/profiling.js b/scripts/profiling/profiling.js new file mode 100644 index 000000000000..c264c17e59d2 --- /dev/null +++ b/scripts/profiling/profiling.js @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 Pagefault Games + * SPDX-FileContributor: Bertie690 + * + * SPDX-License-Identifier: AGPL-3.0-only + * + * This script wraps Vitest's test runner with node's built-in V8 profiler. + * Any extra CLI arguments are passed directly to `vitest run`. + */ + +import { copyFile, glob, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { Command } from "@commander-js/extra-typings"; +import chalk from "chalk"; +import { startVitest } from "vitest/node"; +import { defaultCommanderHelpArgs } from "../helpers/arguments.js"; + +const version = "1.0.0"; + +console.log(chalk.hex("#ffa500")(`📈 Test Profiler - v${version}\n`)); + +const testProfile = new Command("pnpm test:profile") + .description("Run Vitest with Node's V8 profiler and generate processed profiling output.") + .helpOption("-h, --help", "Show this help message.") + .version(version, "-v, --version", "Show the version number.") + .option("-o, --output ", "Directory to which V8 profiler output will be written.", "./temp/vitest-profile") + .option("-c, --cpu", "Enable CPU profiling.", true) + .option("--no-cpu", "Disable CPU profiling.") + .option("-m, --memory", "Enable memory profiling.", true) + .option("--no-memory", "Disable memory profiling.") + .option("--clean", "Whether to clean the output directory before writing new profiles.") + .argument("", "Arguments to pass directly to Vitest.") + .configureHelp(defaultCommanderHelpArgs) + // only show help on argument parsing errors, not on test failures + .showHelpAfterError(true) + .parse(); + +const { output, cpu, memory, clean } = testProfile.opts(); +/** The directory to write profiling outputs to. (May or may not be the final destination.) */ +let outputDir = output; + +if (!cpu && !memory) { + testProfile.error("Cannot disable both CPU and memory profiling!"); +} + +testProfile.showHelpAfterError(false); + +/** + * @returns {Promise} + */ +async function main() { + if (clean) { + await rm(outputDir, { recursive: true, force: true }); + await mkdir(outputDir, { recursive: true }); + } else { + // output profile files to a temp directory before moving them to the desired location, ensuring both exist + outputDir = await mkdtemp(join(tmpdir(), "vitest-profile-"), { encoding: "utf-8" }); + } + + console.log(chalk.grey("Running Vitest with V8 profiler...")); + + /** @type {string[]} */ + const execArgv = []; + if (cpu) { + execArgv.push("--cpu-prof", `--cpu-prof-dir=${outputDir}`); + } + if (memory) { + execArgv.push("--heap-prof", `--heap-prof-dir=${outputDir}`); + } + + const vitest = await startVitest("test", [...testProfile.args, "no-file-parallelism"], { + execArgv, + }); + // NB: This sets `process.exitCode` to a non-zero value if it fails + await vitest.close(); + if (process.exitCode) { + return; + } + + const [cpuProfile, memoryProfile] = await Promise.all([ + (await glob(join(outputDir, "*.cpuprofile")).next()).value, + (await glob(join(outputDir, "*.heapprofile")).next()).value, + ]); + if (!cpuProfile && !memoryProfile) { + console.error(chalk.red.bold("No CPU or memory profile found!")); + process.exitCode = 1; + return; + } + + if (!clean) { + await Promise.all([ + cpuProfile && copyFile(cpuProfile, join(output, basename(cpuProfile))), + memoryProfile && copyFile(memoryProfile, join(output, basename(memoryProfile))), + ]); + } + + if (cpuProfile) { + console.log("Wrote processed CPU profile to:", chalk.bold.blue(cpuProfile)); + } + if (memoryProfile) { + console.log("Wrote processed memory profile to:", chalk.bold.blue(memoryProfile)); + } + + console.log(chalk.green.bold("Profiling complete!")); +} + +await main(); diff --git a/tsconfig.json b/tsconfig.json index 6feb714f2aa2..c66ba815474d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ "ESNext.Iterator", "ESNext.Collection" ], + "types": ["@commander-js/extra-typings"], // Skip typechecking `.d.ts` files for a ~20% speed boost // Won't be necessary if https://github.com/microsoft/TypeScript/issues/30511 is ever resolved "skipLibCheck": true,