diff --git a/README.md b/README.md index d381b8f47f5..73477968bc0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -PokéRogue +
PokéRogue [![Discord Static Badge](https://img.shields.io/badge/Community_Discord-blurple?style=flat&logo=discord&logoSize=auto&labelColor=white&color=5865F2)](https://discord.gg/pokerogue) [![Docs Coverage Static Badge](https://pagefaultgames.github.io/pokerogue/beta/coverage.svg)](https://pagefaultgames.github.io/pokerogue/beta) [![Testing Badge](https://github.com/pagefaultgames/pokerogue/actions/workflows/tests.yml/badge.svg)](https://github.com/pagefaultgames/pokerogue/actions/workflows/tests.yml) -[![License: GNU AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![License: GNU AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
PokéRogue is a browser based Pokémon fangame heavily inspired by the roguelite genre. Battle endlessly while gathering stacking items, exploring many different biomes, fighting trainers, bosses, and more! diff --git a/package.json b/package.json index 0620cf6a88c..f6097b8ccb9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/node": "^22.16.5", "@vitest/coverage-istanbul": "^3.2.4", "@vitest/expect": "^3.2.4", + "@vitest/utils": "^3.2.4", "chalk": "^5.4.1", "dependency-cruiser": "^16.10.4", "inquirer": "^12.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 089689818ac..be3e683f71c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@vitest/expect': specifier: ^3.2.4 version: 3.2.4 + '@vitest/utils': + specifier: ^3.2.4 + version: 3.2.4 chalk: specifier: ^5.4.1 version: 5.4.1 diff --git a/public/locales b/public/locales index 2686cd3edc0..102cbdcd924 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 2686cd3edc0bd2c7a7f12cc54c00c109e51a48d7 +Subproject commit 102cbdcd924e2a7cdc7eab64d1ce79f6ec7604ff diff --git a/src/constants/colors.ts b/src/constants/colors.ts new file mode 100644 index 00000000000..e4d740addff --- /dev/null +++ b/src/constants/colors.ts @@ -0,0 +1,23 @@ +/** + * @module + * A big file storing colors used in logging. + * Minified by Terser during production builds, so has no overhead. + */ + +// Colors used in prod +export const PHASE_START_COLOR = "green" as const; +export const MOVE_COLOR = "RebeccaPurple" as const; + +// Colors used for testing code +export const NEW_TURN_COLOR = "#ffad00ff" as const; +export const UI_MSG_COLOR = "#009dffff" as const; +export const OVERRIDES_COLOR = "#b0b01eff" as const; +export const SETTINGS_COLOR = "#008844ff" as const; + +// Colors used for Vitest-related test utils +export const TEST_NAME_COLOR = "#008886ff" as const; +export const VITEST_PINK_COLOR = "#c162de" as const; + +// Mock console log stuff +export const TRACE_COLOR = "#b700ff" as const; +export const DEBUG_COLOR = "#874600ff" as const; diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 281ac8bd671..68b7d74293b 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -392,7 +392,7 @@ export class PhaseManager { * Helper method to start and log the current phase. */ private startCurrentPhase(): void { - console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:green;"); + console.log(`%cStart Phase ${this.currentPhase.phaseName}`, "color:${PHASE_START_COLOR};"); this.currentPhase.start(); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 9a8e509e302..6587597a0d9 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,4 +1,5 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; +import { MOVE_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; @@ -118,7 +119,10 @@ export class MovePhase extends BattlePhase { public start(): void { super.start(); - console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode)); + console.log( + `%cMove: ${MoveId[this.move.moveId]}\nUse Mode: ${enumValueToKey(MoveUseMode, this.useMode)}`, + `color:${MOVE_COLOR}`, + ); // Check if move is unusable (e.g. running out of PP due to a mid-turn Spite // or the user no longer being on field), ending the phase early if not. diff --git a/test/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index f68141096eb..ae3b4ad8765 100644 --- a/test/abilities/cud-chew.test.ts +++ b/test/abilities/cud-chew.test.ts @@ -99,7 +99,7 @@ describe("Abilities - Cud Chew", () => { expect(abDisplaySpy.mock.calls[1][2]).toBe(false); // should display messgae - expect(game.textInterceptor.getLatestMessage()).toBe( + expect(game.textInterceptor.logs).toContain( i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(farigiraf), }), diff --git a/test/moves/focus-punch.test.ts b/test/moves/focus-punch.test.ts index 9a76dbec0db..d7b40569aaa 100644 --- a/test/moves/focus-punch.test.ts +++ b/test/moves/focus-punch.test.ts @@ -9,7 +9,7 @@ import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; import i18next from "i18next"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Focus Punch", () => { let phaserGame: Phaser.Game; @@ -125,8 +125,8 @@ describe("Moves - Focus Punch", () => { game.move.select(MoveId.FOCUS_PUNCH); await game.phaseInterceptor.to("MoveEndPhase", true); await game.phaseInterceptor.to("MessagePhase", false); - const consoleSpy = vi.spyOn(console, "log"); await game.phaseInterceptor.to("MoveEndPhase", true); - expect(consoleSpy).nthCalledWith(1, i18next.t("moveTriggers:lostFocus", { pokemonName: "Charizard" })); + expect(game.textInterceptor.logs).toContain(i18next.t("moveTriggers:lostFocus", { pokemonName: "Charizard" })); + expect(game.textInterceptor.logs).not.toContain(i18next.t("battle:attackFailed")); }); }); diff --git a/test/test-utils/game-wrapper.ts b/test/test-utils/game-wrapper.ts index 1a906bf8492..d18ea9301ea 100644 --- a/test/test-utils/game-wrapper.ts +++ b/test/test-utils/game-wrapper.ts @@ -6,17 +6,13 @@ import * as bypassLoginModule from "#app/global-vars/bypass-login"; import { MoveAnim } from "#data/battle-anims"; import { Pokemon } from "#field/pokemon"; import { version } from "#package.json"; -import { blobToString } from "#test/test-utils/game-manager-utils"; import { MockClock } from "#test/test-utils/mocks/mock-clock"; -import { MockFetch } from "#test/test-utils/mocks/mock-fetch"; import { MockGameObjectCreator } from "#test/test-utils/mocks/mock-game-object-creator"; import { MockLoader } from "#test/test-utils/mocks/mock-loader"; import { MockTextureManager } from "#test/test-utils/mocks/mock-texture-manager"; import { MockTimedEventManager } from "#test/test-utils/mocks/mock-timed-event-manager"; import { MockContainer } from "#test/test-utils/mocks/mocks-container/mock-container"; import { PokedexMonContainer } from "#ui/pokedex-mon-container"; -import { sessionIdKey } from "#utils/common"; -import { setCookie } from "#utils/cookies"; import fs from "node:fs"; import Phaser from "phaser"; import { vi } from "vitest"; @@ -28,20 +24,6 @@ const GamepadPlugin = Phaser.Input.Gamepad.GamepadPlugin; const EventEmitter = Phaser.Events.EventEmitter; const UpdateList = Phaser.GameObjects.UpdateList; -window.URL.createObjectURL = (blob: Blob) => { - blobToString(blob).then((data: string) => { - localStorage.setItem("toExport", data); - }); - return null; -}; -navigator.getGamepads = () => []; -global.fetch = vi.fn(MockFetch); -setCookie(sessionIdKey, "fake_token"); - -window.matchMedia = () => ({ - matches: false, -}); - export class GameWrapper { public game: Phaser.Game; public scene: BattleScene; @@ -99,6 +81,7 @@ export class GameWrapper { removeAll: () => null, }; + // TODO: Can't we just turn on `noAudio` in audio config? this.scene.sound = { play: () => null, pause: () => null, diff --git a/test/test-utils/helpers/overrides-helper.ts b/test/test-utils/helpers/overrides-helper.ts index b5d07baaa2b..05acf6a3487 100644 --- a/test/test-utils/helpers/overrides-helper.ts +++ b/test/test-utils/helpers/overrides-helper.ts @@ -3,6 +3,7 @@ import type { NewArenaEvent } from "#events/battle-scene"; /** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ +import { OVERRIDES_COLOR } from "#app/constants/colors"; import { TerrainType } from "#app/data/terrain"; import type { BattleStyle, RandomTrainerOverride } from "#app/overrides"; import Overrides from "#app/overrides"; @@ -22,6 +23,7 @@ import type { Variant } from "#sprites/variant"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; import { getEnumStr } from "#test/test-utils/string-utils"; import { coerceArray, shiftCharCodes } from "#utils/common"; +import chalk from "chalk"; import { vi } from "vitest"; /** @@ -681,6 +683,6 @@ export class OverridesHelper extends GameManagerHelper { } private log(...params: any[]) { - console.log("Overrides:", ...params); + console.log(chalk.hex(OVERRIDES_COLOR)(...params)); } } diff --git a/test/test-utils/helpers/settings-helper.ts b/test/test-utils/helpers/settings-helper.ts index a26aa2de33c..46ac74b83dc 100644 --- a/test/test-utils/helpers/settings-helper.ts +++ b/test/test-utils/helpers/settings-helper.ts @@ -1,7 +1,9 @@ +import { SETTINGS_COLOR } from "#app/constants/colors"; import { BattleStyle } from "#enums/battle-style"; import { ExpGainsSpeed } from "#enums/exp-gains-speed"; import { PlayerGender } from "#enums/player-gender"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; +import chalk from "chalk"; /** * Helper to handle settings for tests @@ -49,6 +51,6 @@ export class SettingsHelper extends GameManagerHelper { } private log(...params: any[]) { - console.log("Settings:", ...params); + console.log(chalk.hex(SETTINGS_COLOR)(...params)); } } diff --git a/test/test-utils/mocks/mock-console-log.ts b/test/test-utils/mocks/mock-console-log.ts deleted file mode 100644 index f54d41fea3e..00000000000 --- a/test/test-utils/mocks/mock-console-log.ts +++ /dev/null @@ -1,80 +0,0 @@ -const originalLog = console.log; -const originalError = console.error; -const originalDebug = console.debug; -const originalWarn = console.warn; - -const blacklist = ["Phaser", "variant icon does not exist", 'Texture "%s" not found']; -const whitelist = ["Phase"]; - -export class MockConsoleLog { - constructor( - private logDisabled = false, - private phaseText = false, - ) {} - private logs: any[] = []; - private notified: any[] = []; - - public log(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - if (this.logDisabled && !this.phaseText) { - return; - } - if ((this.phaseText && !whitelist.some(b => argsStr.includes(b))) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalLog(args); - } - public error(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - originalError(args); // Appelle le console.error originel - } - public debug(...args) { - const argsStr = this.getStr(args); - this.logs.push(argsStr); - if (this.logDisabled && !this.phaseText) { - return; - } - if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalDebug(args); - } - warn(...args) { - const argsStr = this.getStr(args); - this.logs.push(args); - if (this.logDisabled && !this.phaseText) { - return; - } - if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) { - return; - } - originalWarn(args); - } - notify(msg) { - originalLog(msg); - this.notified.push(msg); - } - getLogs() { - return this.logs; - } - clearLogs() { - this.logs = []; - } - getStr(...args) { - return args - .map(arg => { - if (typeof arg === "object" && arg !== null) { - // Handle objects including arrays - return JSON.stringify(arg, (_key, value) => (typeof value === "bigint" ? value.toString() : value)); - } - if (typeof arg === "bigint") { - // Handle BigInt values - return arg.toString(); - } - return arg.toString(); - }) - .join(";"); - } -} diff --git a/test/test-utils/mocks/mock-console/color-map.json b/test/test-utils/mocks/mock-console/color-map.json new file mode 100644 index 00000000000..ded83e889b0 --- /dev/null +++ b/test/test-utils/mocks/mock-console/color-map.json @@ -0,0 +1,150 @@ +{ + "AliceBlue": "f0f8ff", + "AntiqueWhite": "faebd7", + "Aqua": "00ffff", + "Aquamarine": "7fffd4", + "Azure": "f0ffff", + "Beige": "f5f5dc", + "Bisque": "ffe4c4", + "Black": "000000", + "BlanchedAlmond": "ffebcd", + "Blue": "0000ff", + "BlueViolet": "8a2be2", + "Brown": "a52a2a", + "BurlyWood": "deb887", + "CadetBlue": "5f9ea0", + "Chartreuse": "7fff00", + "Chocolate": "d2691e", + "Coral": "ff7f50", + "CornflowerBlue": "6495ed", + "Cornsilk": "fff8dc", + "Crimson": "dc143c", + "Cyan": "00ffff", + "DarkBlue": "00008b", + "DarkCyan": "008b8b", + "DarkGoldenRod": "b8860b", + "DarkGray": "a9a9a9", + "DarkGrey": "a9a9a9", + "DarkGreen": "006400", + "DarkKhaki": "bdb76b", + "DarkMagenta": "8b008b", + "DarkOliveGreen": "556b2f", + "DarkOrange": "ff8c00", + "DarkOrchid": "9932cc", + "DarkRed": "8b0000", + "DarkSalmon": "e9967a", + "DarkSeaGreen": "8fbc8f", + "DarkSlateBlue": "483d8b", + "DarkSlateGray": "2f4f4f", + "DarkSlateGrey": "2f4f4f", + "DarkTurquoise": "00ced1", + "DarkViolet": "9400d3", + "DeepPink": "ff1493", + "DeepSkyBlue": "00bfff", + "DimGray": "696969", + "DimGrey": "696969", + "DodgerBlue": "1e90ff", + "FireBrick": "b22222", + "FloralWhite": "fffaf0", + "ForestGreen": "228b22", + "Fuchsia": "ff00ff", + "Gainsboro": "dcdcdc", + "GhostWhite": "f8f8ff", + "Gold": "ffd700", + "GoldenRod": "daa520", + "Gray": "808080", + "Grey": "808080", + "Green": "008000", + "GreenYellow": "adff2f", + "HoneyDew": "f0fff0", + "HotPink": "ff69b4", + "IndianRed": "cd5c5c", + "Indigo": "4b0082", + "Ivory": "fffff0", + "Khaki": "f0e68c", + "Lavender": "e6e6fa", + "LavenderBlush": "fff0f5", + "LawnGreen": "7cfc00", + "LemonChiffon": "fffacd", + "LightBlue": "add8e6", + "LightCoral": "f08080", + "LightCyan": "e0ffff", + "LightGoldenRodYellow": "fafad2", + "LightGray": "d3d3d3", + "LightGrey": "d3d3d3", + "LightGreen": "90ee90", + "LightPink": "ffb6c1", + "LightSalmon": "ffa07a", + "LightSeaGreen": "20b2aa", + "LightSkyBlue": "87cefa", + "LightSlateGray": "778899", + "LightSlateGrey": "778899", + "LightSteelBlue": "b0c4de", + "LightYellow": "ffffe0", + "Lime": "00ff00", + "LimeGreen": "32cd32", + "Linen": "faf0e6", + "Magenta": "ff00ff", + "Maroon": "800000", + "MediumAquaMarine": "66cdaa", + "MediumBlue": "0000cd", + "MediumOrchid": "ba55d3", + "MediumPurple": "9370db", + "MediumSeaGreen": "3cb371", + "MediumSlateBlue": "7b68ee", + "MediumSpringGreen": "00fa9a", + "MediumTurquoise": "48d1cc", + "MediumVioletRed": "c71585", + "MidnightBlue": "191970", + "MintCream": "f5fffa", + "MistyRose": "ffe4e1", + "Moccasin": "ffe4b5", + "NavajoWhite": "ffdead", + "Navy": "000080", + "OldLace": "fdf5e6", + "Olive": "808000", + "OliveDrab": "6b8e23", + "Orange": "ffa500", + "OrangeRed": "ff4500", + "Orchid": "da70d6", + "PaleGoldenRod": "eee8aa", + "PaleGreen": "98fb98", + "PaleTurquoise": "afeeee", + "PaleVioletRed": "db7093", + "PapayaWhip": "ffefd5", + "PeachPuff": "ffdab9", + "Peru": "cd853f", + "Pink": "ffc0cb", + "Plum": "dda0dd", + "PowderBlue": "b0e0e6", + "Purple": "800080", + "RebeccaPurple": "663399", + "Red": "ff0000", + "RosyBrown": "bc8f8f", + "RoyalBlue": "4169e1", + "SaddleBrown": "8b4513", + "Salmon": "fa8072", + "SandyBrown": "f4a460", + "SeaGreen": "2e8b57", + "SeaShell": "fff5ee", + "Sienna": "a0522d", + "Silver": "c0c0c0", + "SkyBlue": "87ceeb", + "SlateBlue": "6a5acd", + "SlateGray": "708090", + "SlateGrey": "708090", + "Snow": "fffafa", + "SpringGreen": "00ff7f", + "SteelBlue": "4682b4", + "Tan": "d2b48c", + "Teal": "008080", + "Thistle": "d8bfd8", + "Tomato": "ff6347", + "Turquoise": "40e0d0", + "Violet": "ee82ee", + "Wheat": "f5deb3", + "White": "ffffff", + "WhiteSmoke": "f5f5f5", + "Yellow": "ffff00", + "YellowGreen": "9acd32" +} diff --git a/test/test-utils/mocks/mock-console/infer-color.ts b/test/test-utils/mocks/mock-console/infer-color.ts new file mode 100644 index 00000000000..e01adbc4ad4 --- /dev/null +++ b/test/test-utils/mocks/mock-console/infer-color.ts @@ -0,0 +1,61 @@ +import { hslToHex } from "#utils/common"; +import chalk, { type ChalkInstance, type ForegroundColorName, foregroundColorNames } from "chalk"; +import colorMap from "./color-map.json"; + +export function inferColorFormat(data: [string, ...unknown[]]): ChalkInstance { + // Remove all CSS format strings and find the first one containing something vaguely resembling a color + data[0] = data[0].replaceAll("%c", ""); + const args = data.slice(1).filter(t => typeof t === "string"); + const color = findColorPrefix(args); + + // If the color is within Chalk's native roster, use it directly. + if ((foregroundColorNames as string[]).includes(color)) { + return chalk[color as ForegroundColorName]; + } + + // Otherwise, coerce it to hex before feeding it in. + return getColor(color); +} + +/** + * Find the first string with a "color:" CSS directive in an argument list. + * @param args - The arguments containing the color directive + * @returns The found color, or `"green"` if none were found + */ +function findColorPrefix(args: string[]): string { + for (const arg of args) { + const match = /color:\s*(.+?)(?:;|$)/g.exec(arg); + if (match === null) { + continue; + } + + return match[1]; + } + return "green"; +} + +/** + * Coerce an arbitrary CSS color string to a Chalk instance. + * @param color - The color to coerce + * @returns The Chalk color equivalent. + */ +function getColor(color: string): ChalkInstance { + if (/^#([a-z0-9]{3,4}|[a-z0-9]{6}|[a-z0-9]{8})$/i.test(color)) { + // already in hex + return chalk.hex(color); + } + + const rgbMatch = /^rgba?\((\d{1,3})%?,\s*(\d{1,3})%?,?\s*(\d{1,3})%?,\s*/i.exec(color); + if (rgbMatch) { + const [red, green, blue] = rgbMatch; + return chalk.rgb(+red, +green, +blue); + } + + const hslMatch = /^hslv?\((\d{1,3}),\s*(\d{1,3})%,\s*(\d{1,3})%\)$/i.exec(color); + if (hslMatch) { + const [hue, saturation, light] = hslMatch; + return chalk.hex(hslToHex(+hue, +saturation / 100, +light / 100)); + } + + return chalk.hex(colorMap[color] ?? "#00ff95ff"); +} diff --git a/test/test-utils/mocks/mock-console/mock-console.ts b/test/test-utils/mocks/mock-console/mock-console.ts new file mode 100644 index 00000000000..52ed0af6aa7 --- /dev/null +++ b/test/test-utils/mocks/mock-console/mock-console.ts @@ -0,0 +1,211 @@ +import { DEBUG_COLOR, NEW_TURN_COLOR, TRACE_COLOR, UI_MSG_COLOR } from "#app/constants/colors"; +import { inferColorFormat } from "#test/test-utils/mocks/mock-console/infer-color"; +import { coerceArray } from "#utils/common"; +import { type InspectOptions, inspect } from "node:util"; +import chalk, { type ChalkInstance } from "chalk"; + +// Tell chalk we support truecolor +chalk.level = 3; + +// TODO: Review this +const blacklist = [ + "variant icon does not exist", // Repetitive warnings about icons not found + 'Texture "%s" not found', // Repetitive warnings about textures not found + "type: 'Pokemon',", // Large Pokemon objects + "gameVersion: ", // Large session-data and system-data objects + "Phaser v", // Phaser version text + "Seed:", // Stuff about wave seed (we should really stop logging this shit) + "Wave Seed:", // Stuff about wave seed (we should really stop logging this shit) +] as const; +const whitelist = ["Start Phase"] as const; + +const inspectOptions: InspectOptions = { sorted: true, breakLength: 120, numericSeparator: true }; + +/** + * The {@linkcode MockConsole} is a wrapper around the global {@linkcode console} object. + * It automatically colors text and such. + */ +export class MockConsole implements Omit { + /** + * A list of warnings that are queued to be displayed after all tests in the same file are finished. + */ + private static readonly queuedWarnings: unknown[][] = []; + /** + * The original `Console` object, preserved to avoid overwriting + * Vitest's native `console.log` wrapping. + */ + private console = console; + + //#region Static Properties + + /** + * Queue a warning to be printed after all tests in the same file are finished. + */ + // TODO: Add some warnings + public static queuePostTestWarning(...data: unknown[]): void { + MockConsole.queuedWarnings.push(data); + } + + /** + * Print and reset all post-test warnings. + */ + public static printPostTestWarnings(): void { + for (const data of MockConsole.queuedWarnings) { + console.warn(...data); + } + MockConsole.queuedWarnings.splice(0); + } + + //#endregion Private Properties + + //#region Utilities + + /** + * Check whether a given set of data is in the blacklist to be barred from logging. + * @param data - The data being logged + * @returns Whether `data` is blacklisted from console logging + */ + private checkBlacklist(data: unknown[]): boolean { + const dataStr = this.getStr(data); + return !whitelist.some(b => dataStr.includes(b)) && blacklist.some(b => dataStr.includes(b)); + } + + /** + * Returns a human-readable string representation of `data`. + */ + private getStr(data: unknown): string { + return inspect(data, inspectOptions); + } + + /** + * Stringify the given data in a manner fit for logging. + * @param color - A Chalk instance or other transformation function used to transform the output, + * or `undefined` to not transform it at all. + * @param data - The data that the format should be applied to. + * @returns A stringified copy of `data` with {@linkcode color} applied to each individual argument. + * @todo Do we need to apply color to each entry or just run it through `util.format`? + */ + private format(color: ((s: unknown) => unknown) | undefined, data: unknown | unknown[]): unknown[] { + data = coerceArray(data); + color ??= a => a; + return (data as unknown[]).map(a => color(typeof a === "function" || typeof a === "object" ? this.getStr(a) : a)); + } + + //#endregion Utilities + + //#region Custom wrappers + public info(...data: unknown[]) { + return this.log(...data); + } + + public trace(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + // TODO: Figure out how to add color to the full trace text + this.console.trace(...this.format(chalk.hex(TRACE_COLOR), data)); + } + + public debug(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + this.console.debug(...this.format(chalk.hex(DEBUG_COLOR), data)); + } + + public log(...data: unknown[]): void { + if (this.checkBlacklist(data)) { + return; + } + + let formatter: ChalkInstance | undefined; + + if (data.some(d => typeof d === "string" && d.includes("color:"))) { + // Infer the color format from the arguments, then remove everything but the message. + formatter = inferColorFormat(data as [string, ...unknown[]]); + data.splice(1); + } else if (data[0] === "[UI]") { + // Cyan for UI debug messages + formatter = chalk.hex(UI_MSG_COLOR); + } else if (typeof data[0] === "string" && data[0].startsWith("=====")) { + // Orange logging for "New Turn"/etc messages + formatter = chalk.hex(NEW_TURN_COLOR); + } + + this.console.log(...this.format(formatter, data)); + } + + public warn(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + this.console.warn(...this.format(chalk.yellow, data)); + } + + public error(...data: unknown[]) { + if (this.checkBlacklist(data)) { + return; + } + + this.console.error(...this.format(chalk.redBright, data)); + } + + //#endregion Custom Wrappers + + //#region Copy-pasted Console code + // TODO: Progressively add proper coloration and support for all these methods + public dir(...args: Parameters<(typeof console)["dir"]>): ReturnType<(typeof console)["dir"]> { + return this.console.dir(...args); + } + public dirxml(...args: Parameters<(typeof console)["dirxml"]>): ReturnType<(typeof console)["dirxml"]> { + return this.console.dirxml(...args); + } + public table(...args: Parameters<(typeof console)["table"]>): ReturnType<(typeof console)["table"]> { + return this.console.table(...args); + } + public group(...args: Parameters<(typeof console)["group"]>): ReturnType<(typeof console)["group"]> { + return this.console.group(...args); + } + public groupCollapsed( + ...args: Parameters<(typeof console)["groupCollapsed"]> + ): ReturnType<(typeof console)["groupCollapsed"]> { + return this.console.groupCollapsed(...args); + } + public groupEnd(...args: Parameters<(typeof console)["groupEnd"]>): ReturnType<(typeof console)["groupEnd"]> { + return this.console.groupEnd(...args); + } + public clear(...args: Parameters<(typeof console)["clear"]>): ReturnType<(typeof console)["clear"]> { + return this.console.clear(...args); + } + public count(...args: Parameters<(typeof console)["count"]>): ReturnType<(typeof console)["count"]> { + return this.console.count(...args); + } + public countReset(...args: Parameters<(typeof console)["countReset"]>): ReturnType<(typeof console)["countReset"]> { + return this.console.countReset(...args); + } + public assert(...args: Parameters<(typeof console)["assert"]>): ReturnType<(typeof console)["assert"]> { + return this.console.assert(...args); + } + public profile(...args: Parameters<(typeof console)["profile"]>): ReturnType<(typeof console)["profile"]> { + return this.console.profile(...args); + } + public profileEnd(...args: Parameters<(typeof console)["profileEnd"]>): ReturnType<(typeof console)["profileEnd"]> { + return this.console.profileEnd(...args); + } + public time(...args: Parameters<(typeof console)["time"]>): ReturnType<(typeof console)["time"]> { + return this.console.time(...args); + } + public timeLog(...args: Parameters<(typeof console)["timeLog"]>): ReturnType<(typeof console)["timeLog"]> { + return this.console.timeLog(...args); + } + public timeEnd(...args: Parameters<(typeof console)["timeEnd"]>): ReturnType<(typeof console)["timeEnd"]> { + return this.console.timeEnd(...args); + } + public timeStamp(...args: Parameters<(typeof console)["timeStamp"]>): ReturnType<(typeof console)["timeStamp"]> { + return this.console.timeStamp(...args); + } + //#endregion Copy-pasted Console code +} diff --git a/test/test-utils/mocks/mocks-container/mock-text.ts b/test/test-utils/mocks/mocks-container/mock-text.ts index ad2fce80972..a64aa45ef80 100644 --- a/test/test-utils/mocks/mocks-container/mock-text.ts +++ b/test/test-utils/mocks/mocks-container/mock-text.ts @@ -1,4 +1,5 @@ import type { MockGameObject } from "#test/test-utils/mocks/mock-game-object"; +import type { TextInterceptor } from "#test/test-utils/text-interceptor"; import { UI } from "#ui/ui"; export class MockText implements MockGameObject { @@ -82,13 +83,14 @@ export class MockText implements MockGameObject { showText( text: string, - delay?: number | null, + _delay?: number | null, callback?: Function | null, - callbackDelay?: number | null, - prompt?: boolean | null, - promptDelay?: number | null, + _callbackDelay?: number | null, + _prompt?: boolean | null, + _promptDelay?: number | null, ) { - this.scene.messageWrapper.showText(text, delay, callback, callbackDelay, prompt, promptDelay); + // TODO: this is a very bad way to pass calls around + (this.scene.messageWrapper as TextInterceptor).showText(text); if (callback) { callback(); } @@ -96,13 +98,13 @@ export class MockText implements MockGameObject { showDialogue( keyOrText: string, - name: string | undefined, - delay: number | null = 0, + name: string, + _delay: number | null, callback: Function, - callbackDelay?: number, - promptDelay?: number, + _callbackDelay?: number, + _promptDelay?: number, ) { - this.scene.messageWrapper.showDialogue(keyOrText, name, delay, callback, callbackDelay, promptDelay); + (this.scene.messageWrapper as TextInterceptor).showDialogue(keyOrText, name); if (callback) { callback(); } diff --git a/test/test-utils/reporters/custom-default-reporter.ts b/test/test-utils/reporters/custom-default-reporter.ts new file mode 100644 index 00000000000..15c4881b83c --- /dev/null +++ b/test/test-utils/reporters/custom-default-reporter.ts @@ -0,0 +1,62 @@ +import { relative } from "node:path"; +import { parseStacktrace } from "@vitest/utils/source-map"; +import chalk from "chalk"; +import type { UserConsoleLog } from "vitest"; +import type { TestState } from "vitest/node"; +import { DefaultReporter } from "vitest/reporters"; + +/** + * Custom Vitest reporter to strip the current file names from the output. + */ +export default class CustomDefaultReporter extends DefaultReporter { + public override onUserConsoleLog(log: UserConsoleLog, taskState?: TestState): void { + // This code is more or less copied verbatim from `vitest/reporters` source, with minor tweaks to use + // dependencies we actually _have_ (i.e. chalk) rather than ones we don't (i.e. tinyrainbow). + + // SPDX-SnippetBegin + // SPDX-SnippetCopyrightText: 2021 VoidZero Inc. and Vitest contributors + // SPDX-License-Identifier: MIT + + if (!super.shouldLog(log, taskState)) { + return; + } + + const output = log.type === "stdout" ? this.ctx.logger.outputStream : this.ctx.logger.errorStream; + + const write = (msg: string) => output.write(msg); + + const task = log.taskId ? this.ctx.state.idMap.get(log.taskId) : undefined; + + write(log.content); // this is about the only changed line (that and us skipping a newline) + + if (!log.origin) { + return; + } + + // Code for stack trace, ripped directly out of Vitest source code. + // I wish they had a helper function to do this so we didn't have to import `@vitest/utils`, but oh well... + // browser logs don't have an extra end of line at the end like Node.js does + if (log.browser) { + write("\n"); + } + + const project = task ? this.ctx.getProjectByName(task.file.projectName ?? "") : this.ctx.getRootProject(); + + const stack = log.browser ? (project.browser?.parseStacktrace(log.origin) ?? []) : parseStacktrace(log.origin); + + const highlight = task && stack.find(i => i.file === task.file.filepath); + + for (const frame of stack) { + const color = frame === highlight ? chalk.cyan : chalk.gray; + const path = relative(project.config.root, frame.file); + + const positions = [frame.method, `${path}:${chalk.dim(`${frame.line}:${frame.column}`)}`] + .filter(Boolean) + .join(" "); + + write(color(` ${chalk.dim(">")} ${positions}\n`)); + } + + // SPDX-SnippetEnd + } +} diff --git a/test/test-utils/setup/test-end-log.ts b/test/test-utils/setup/test-end-log.ts new file mode 100644 index 00000000000..9814ba8a45c --- /dev/null +++ b/test/test-utils/setup/test-end-log.ts @@ -0,0 +1,113 @@ +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type CustomDefaultReporter from "#test/test-utils/reporters/custom-default-reporter"; +import { basename, join, relative } from "path"; +import chalk from "chalk"; +import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; + +/** + * @module + * Code to add markers to the beginning and end of tests. + * Intended for use with {@linkcode CustomDefaultReporter}, and placed inside test hooks + * (rather than as part of the reporter) to ensure Vitest waits for the log messages to be printed. + */ + +/** A long string of "="s to partition off each test from one another. */ +const TEST_END_BARRIER = chalk.bold.hex("#ff7c7cff")("=================="); + +// Colors used for Vitest-related test utils +const TEST_NAME_COLOR = "#008886ff" as const; +const VITEST_PINK_COLOR = "#c162de" as const; + +const testRoot = join(import.meta.dirname, "..", "..", ".."); + +/** + * Log the testfile name and path upon a case starting. \ + * Used to compensate for us overridding the global Console object and removing Vitest's + * test name annotations. + * @param test - The {@linkcode RunnerTask} passed from the context + */ +export function logTestStart(test: RunnerTask): void { + console.log(TEST_END_BARRIER); + console.log( + `${chalk.dim("> ")}${chalk.hex(VITEST_PINK_COLOR)("Starting test: ")}${chalk.hex(TEST_NAME_COLOR)(getTestName(test))}`, + ); +} + +/** + * Log the testfile name, path and result upon a case ending. \ + * Used to compensate for us overridding the global Console object and removing Vitest's + * test name annotations. + * @param task - The {@linkcode RunnerTestCase} passed from the hook + */ +export function logTestEnd(task: RunnerTestCase): void { + const durationStr = getDurationPrefix(task.result); + const resultStr = getResultStr(task.result); + console.log(`${chalk.dim("> ")}${chalk.black.bgHex(VITEST_PINK_COLOR)(" Test finished! ")} + Name: ${chalk.hex(TEST_NAME_COLOR)(getTestName(task))} + Result: ${resultStr}${durationStr} + File: ${chalk.hex("#d29b0eff")( + getPathFromTest(task.file.filepath) + (task.location ? `:${task.location.line}:${task.location.column}` : ""), + )}`); +} + +/** + * Get the path of the current test file relative to the `test` directory. + * @param abs - The absolute path to the file + * @returns The relative path with `test/` appended to it. + */ +function getPathFromTest(abs: string): string { + return join(basename(testRoot), relative(testRoot, abs)); +} + +function getResultStr(result: RunnerTaskResult | undefined): string { + if (result?.state !== "pass" && result?.state !== "fail") { + return "Unknown"; + } + + const resultStr = + result.state === "pass" + ? chalk.green.bold("✔ Passed") + : (result?.duration ?? 0) > 2 + ? chalk.cyan.bold("◴ Timed out") + : chalk.red.bold("✗ Failed"); + + return resultStr; +} + +/** + * Get the text to be displayed for a test's duration. + * @param result - The {@linkcode RunnerTaskResult} of the finished test + * @returns An appropriately colored suffix for the start time. + * Will return an empty string if `result.startTime` is `undefined` + */ +function getDurationPrefix(result?: RunnerTaskResult): string { + const startTime = result?.startTime; + if (!startTime) { + return ""; + } + const duration = Math.round(Date.now() - startTime); + + // TODO: Figure out a way to access the current vitest config from a hook + const color = duration > 10_000 ? chalk.yellow : chalk.green; + return ` ${chalk.dim("in")} ${color(duration)}${chalk.dim("ms")}`; +} + +// Function copied from vitest source to avoid having to import `@vitest/runner/utils` for 1 function + +// SPDX-SnippetBegin +// SPDX-SnippetCopyrightText: 2021 VoidZero Inc. and Vitest contributors +// SPDX-License-Identifier: MIT +function getTestName(task: RunnerTask, separator = " > "): string { + const names: string[] = [task.name]; + let current: RunnerTask | undefined = task; + + while ((current = current?.suite)) { + if (current?.name) { + names.unshift(current.name); + } + } + + return names.join(separator); +} + +// SPDX-SnippetEnd diff --git a/test/test-utils/test-file-initialization.ts b/test/test-utils/test-file-initialization.ts index 631d3f9146b..c172e2d1da8 100644 --- a/test/test-utils/test-file-initialization.ts +++ b/test/test-utils/test-file-initialization.ts @@ -3,7 +3,7 @@ import { initializeGame } from "#app/init/init"; import { initI18n } from "#plugins/i18n"; import { blobToString } from "#test/test-utils/game-manager-utils"; import { manageListeners } from "#test/test-utils/listeners-manager"; -import { MockConsoleLog } from "#test/test-utils/mocks/mock-console-log"; +import { MockConsole } from "#test/test-utils/mocks/mock-console/mock-console"; import { mockContext } from "#test/test-utils/mocks/mock-context-canvas"; import { mockLocalStorage } from "#test/test-utils/mocks/mock-local-storage"; import { MockImage } from "#test/test-utils/mocks/mocks-container/mock-image"; @@ -38,14 +38,22 @@ function initTestFile(): void { /** * Setup various stubs for testing. * @todo Move this into a dedicated stub file instead of running it once per test instance + * @todo review these to see which are actually necessary * @todo Investigate why this resets on new test suite start */ function setupStubs(): void { - Object.defineProperty(window, "localStorage", { - value: mockLocalStorage(), - }); - Object.defineProperty(window, "console", { - value: new MockConsoleLog(false), + Object.defineProperties(global, { + localStorage: { + value: mockLocalStorage(), + }, + console: { + value: new MockConsole(), + }, + matchMedia: { + value: () => ({ + matches: false, + }), + }, }); Object.defineProperty(document, "fonts", { writable: true, @@ -69,11 +77,6 @@ function setupStubs(): void { navigator.getGamepads = () => []; setCookie(SESSION_ID_COOKIE_NAME, "fake_token"); - window.matchMedia = () => - ({ - matches: false, - }) as any; - /** * Sets this object's position relative to another object with a given offset * @param guideObject - The {@linkcode Phaser.GameObjects.GameObject} to base the position off of diff --git a/test/test-utils/text-interceptor.ts b/test/test-utils/text-interceptor.ts index 36a5db4c78d..dfbaf2ff11c 100644 --- a/test/test-utils/text-interceptor.ts +++ b/test/test-utils/text-interceptor.ts @@ -1,39 +1,49 @@ +import type { BattleScene } from "#app/battle-scene"; +import chalk from "chalk"; + /** - * Class will intercept any text or dialogue message calls and log them for test purposes + * The {@linkcode TextInterceptor} is a wrapper class that intercepts and logs any messages + * that would be displayed on-screen. */ export class TextInterceptor { - private scene; - public logs: string[] = []; - constructor(scene) { - this.scene = scene; + /** A log containing messages having been displayed on screen, sorted in FIFO order. */ + public readonly logs: string[] = []; + + constructor(scene: BattleScene) { + // @ts-expect-error: Find another more sanitary way of doing this scene.messageWrapper = this; } - showText( - text: string, - _delay?: number, - _callback?: Function, - _callbackDelay?: number, - _prompt?: boolean, - _promptDelay?: number, - ): void { - console.log(text); + /** Clear the current content of the TextInterceptor. */ + public clearLogs(): void { + this.logs.splice(0); + } + + showText(text: string): void { + // NB: We do not format the raw _logs_ themselves as tests will be actively checking it. + console.log(this.formatText(text)); this.logs.push(text); } - showDialogue( - text: string, - name: string, - _delay?: number, - _callback?: Function, - _callbackDelay?: number, - _promptDelay?: number, - ): void { - console.log(name, text); + showDialogue(text: string, name: string): void { + console.log(`${name}: \n${this.formatText(text)}`); this.logs.push(name, text); } - getLatestMessage(): string { - return this.logs.pop() ?? ""; + /** + * Format text to be displayed to the test console, as follows: + * 1. Replaces new lines and new text boxes (marked by `$`) with indented new lines. + * 2. Removes all `@c{}`, `@d{}`, `@s{}`, and `@f{}` flags from the text. + * 3. Makes text blue + * @param text - The unformatted text + * @returns The formatted text + */ + private formatText(text: string): string { + return chalk.blue( + text + .replace(/\n/g, " ") + .replace(/\$/g, "\n ") + .replace(/@\w{.*?}/g, ""), + ); } } diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index be35e18e2e9..23adab01a05 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -1,16 +1,21 @@ import "vitest-canvas-mock"; +import { MockConsole } from "#test/test-utils/mocks/mock-console/mock-console"; +import { logTestEnd, logTestStart } from "#test/test-utils/setup/test-end-log"; import { initTests } from "#test/test-utils/test-file-initialization"; -import { afterAll, beforeAll, vi } from "vitest"; +import chalk from "chalk"; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; -/** Set the timezone to UTC for tests. */ +//#region Mocking -/** Mock the override import to always return default values, ignoring any custom overrides. */ -vi.mock("#app/overrides", async importOriginal => { - const { defaultOverrides } = await importOriginal(); +// Mock the override import to always return default values, ignoring any custom overrides. +vi.mock(import("#app/overrides"), async importOriginal => { + const { defaultOverrides } = await importOriginal(); return { default: defaultOverrides, - defaultOverrides, + // Export `defaultOverrides` as a *copy*. + // This ensures we can easily reset `overrides` back to its default values after modifying it. + defaultOverrides: { ...defaultOverrides }, } satisfies typeof import("#app/overrides"); }); @@ -20,7 +25,7 @@ vi.mock("#app/overrides", async importOriginal => { * This is necessary because how our code is structured. * Do NOT try to put any of this code into external functions, it won't work as it's elevated during runtime. */ -vi.mock("i18next", async importOriginal => { +vi.mock(import("i18next"), async importOriginal => { console.log("Mocking i18next"); const { setupServer } = await import("msw/node"); const { http, HttpResponse } = await import("msw"); @@ -30,8 +35,11 @@ vi.mock("i18next", async importOriginal => { const filename = req.params[0]; try { - const json = await import(`../public/locales/en/${req.params[0]}`); - console.log("Loaded locale", filename); + const localeFiles = import.meta.glob("../public/locales/en/**/*.json", { eager: true }); + const json = localeFiles[`../public/locales/en/${filename}`] || {}; + if (import.meta.env.VITE_I18N_DEBUG === "1") { + console.log("Loaded locale", filename); + } return HttpResponse.json(json); } catch (err) { console.log(`Failed to load locale ${filename}!`, err); @@ -54,7 +62,15 @@ beforeAll(() => { initTests(); }); +beforeEach(context => { + logTestStart(context.task); +}); +afterEach(context => { + logTestEnd(context.task); +}); + afterAll(() => { global.server.close(); - console.log("Closing i18n MSW server!"); + MockConsole.printPostTestWarnings(); + console.log(chalk.hex("#dfb8d8")("Closing i18n MSW server!")); }); diff --git a/typedoc.config.js b/typedoc.config.js index 1f944cd544e..ef932a5d077 100644 --- a/typedoc.config.js +++ b/typedoc.config.js @@ -8,7 +8,13 @@ const dryRun = !!process.env.DRY_RUN?.match(/true/gi); const config = { entryPoints: ["./src", "./test/test-utils"], entryPointStrategy: "expand", - exclude: ["**/*+.test.ts", "src/polyfills.ts", "src/vite.env.d.ts"], + exclude: [ + "src/polyfills.ts", + "src/vite.env.d.ts", + "**/*+.test.ts", + "test/test-utils/setup", + "test/test-utils/reporters", + ], excludeReferences: true, // prevent documenting re-exports requiredToBeDocumented: [ "Enum", diff --git a/vitest.config.ts b/vitest.config.ts index 65c5427e591..7fa2494bb4e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,18 +1,25 @@ -import { defineProject } from "vitest/config"; +import { defineConfig } from "vitest/config"; import { BaseSequencer, type TestSpecification } from "vitest/node"; import { defaultConfig } from "./vite.config"; -export default defineProject(({ mode }) => ({ +export default defineConfig(({ mode }) => ({ ...defaultConfig, test: { + reporters: process.env.GITHUB_ACTIONS + ? ["github-actions", "./test/test-utils/reporters/custom-default-reporter.ts"] + : ["./test/test-utils/reporters/custom-default-reporter.ts"], env: { TZ: "UTC", }, - testTimeout: 20000, + testTimeout: 20_000, + slowTestThreshold: 10_000, + // TODO: Consider enabling + // expect: {requireAssertions: true}, setupFiles: ["./test/font-face.setup.ts", "./test/vitest.setup.ts", "./test/matchers.setup.ts"], sequence: { sequencer: MySequencer, }, + includeTaskLocation: true, environment: "jsdom" as const, environmentOptions: { jsdom: {