Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

temp
node_modules
dist
dist-ssr
Expand Down
22 changes: 19 additions & 3 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"vcs": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions scripts/changelog-reader/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 79 additions & 1 deletion scripts/helpers/arguments.js
Original file line number Diff line number Diff line change
@@ -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`).
Expand All @@ -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".
Expand All @@ -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<Help>}
* @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;
}
19 changes: 19 additions & 0 deletions scripts/helpers/process.js
Original file line number Diff line number Diff line change
@@ -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;
}
108 changes: 108 additions & 0 deletions scripts/profiling/profiling.js
Original file line number Diff line number Diff line change
@@ -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 <path>", "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("<vitest-args...>", "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<void>}
*/
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();
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down