[Dev] Ensure i18n module is initialized immediately when imported (#6317)

* [Dev] Ensure `i18n` module is initialized immediately when imported

* Fixed missing await?

* Update src/main.ts

* Update init.ts

* Update src/main.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/plugins/i18n.ts

* Update trainer-config.ts

* ran biome & made `@module` comment

* Update src/plugins/i18n.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/plugins/i18n.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Fixed import typo

* flubber

* Ran Biome

* foo

* Remove default re-export of `i18next`

* Update i18n.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* fixde import issues

* Move `i18n` initialization to `main.ts` from `init.ts`

* Remove some redundant & incorrect comments from `trainer-config.ts`

* Fix tests

* Apply Biome

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Bertie690 2025-12-17 21:39:30 -05:00 committed by GitHub
parent b2b8150856
commit f48ec4c51e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 99 additions and 197 deletions

View File

@ -18,7 +18,6 @@ import { Trainer } from "#field/trainer";
import { MoneyMultiplierModifier, type PokemonHeldItemModifier } from "#modifiers/modifier";
import type { CustomModifierSettings } from "#modifiers/modifier-type";
import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import i18next from "#plugins/i18n";
import { MusicPreference } from "#system/settings";
import { trainerConfigs } from "#trainers/trainer-config";
import type { TurnMove } from "#types/turn-move";
@ -33,6 +32,7 @@ import {
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { randSeedUniqueItem } from "#utils/random";
import i18next from "i18next";
export interface TurnCommand {
command: Command;

View File

@ -82,7 +82,6 @@ import type { Move } from "#moves/move";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MovePhase } from "#phases/move-phase";
import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase";
import i18next from "#plugins/i18n";
import type {
AbilityBattlerTagType,
BattlerTagData,
@ -103,6 +102,7 @@ import type { Mutable } from "#types/type-helpers";
import { coerceArray } from "#utils/array";
import { BooleanHolder, getFrameMs, NumberHolder, toDmgValue } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
/** Interface containing the serializable fields of `BattlerTag` */
interface BaseBattlerTag {

View File

@ -34,10 +34,10 @@ import {
import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import i18next from "#plugins/i18n";
import { PokemonData } from "#system/pokemon-data";
import { randSeedItem } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import i18next from "i18next";
/** the i18n namespace for the encounter */
const namespace = "mysteryEncounters/berriesAbound";

View File

@ -32,10 +32,10 @@ import {
HeldItemRequirement,
MoneyRequirement,
} from "#mystery-encounters/mystery-encounter-requirements";
import i18next from "#plugins/i18n";
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
import { randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";
/** the i18n namespace for this encounter */
const namespace = "mysteryEncounters/delibirdy";

View File

@ -26,9 +26,9 @@ import {
import { applyModifierTypeToPlayerPokemon } from "#mystery-encounters/encounter-pokemon-utils";
import { type MysteryEncounter, MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import i18next from "#plugins/i18n";
import { randSeedInt } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";
/** the i18n namespace for this encounter */
const namespace = "mysteryEncounters/trashToTreasure";

View File

@ -35,7 +35,6 @@ import {
import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import i18next from "#plugins/i18n";
import { achvs } from "#system/achv";
import { PokemonData } from "#system/pokemon-data";
import { trainerConfigs } from "#trainers/trainer-config";
@ -43,6 +42,7 @@ import { TrainerPartyTemplate } from "#trainers/trainer-party-template";
import type { HeldModifierConfig } from "#types/held-modifier-config";
import { NumberHolder, randSeedInt, randSeedShuffle } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";
/** i18n namespace for encounter */
const namespace = "mysteryEncounters/weirdDream";

View File

@ -21,7 +21,6 @@ import { TrainerVariant } from "#enums/trainer-variant";
import type { EnemyPokemon } from "#field/pokemon";
import type { SpeciesStatBoosterModifier } from "#modifiers/modifier";
import { PokemonMove } from "#moves/pokemon-move";
import { getIsInitialized, initI18n } from "#plugins/i18n";
import type { EvilTeam } from "#trainers/evil-admin-trainer-pools";
import { evilAdminTrainerPools } from "#trainers/evil-admin-trainer-pools";
import {
@ -211,14 +210,8 @@ export class TrainerConfig {
setName(name: string): TrainerConfig {
if (name === "Finn") {
// Give the rival a localized name
// First check if i18n is initialized
if (!getIsInitialized()) {
initI18n();
}
// This is only the male name, because the female name is handled in a different function (setHasGenders)
if (name === "Finn") {
name = i18next.t("trainerNames:rival");
}
name = i18next.t("trainerNames:rival");
}
this.name = name;
@ -235,11 +228,6 @@ export class TrainerConfig {
}
setTitle(title: string): TrainerConfig {
// First check if i18n is initialized
if (!getIsInitialized()) {
initI18n();
}
title = toCamelCase(title);
// Get the title from the i18n file
@ -328,11 +316,6 @@ export class TrainerConfig {
setHasGenders(nameFemale?: string, femaleEncounterBgm?: TrainerType | string): TrainerConfig {
// If the female name is 'Ivy' (the rival), assign a localized name.
if (nameFemale === "Ivy") {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
// Initialize the i18n system if it is not already initialized.
initI18n();
}
// Set the localized name for the female rival.
this.nameFemale = i18next.t("trainerNames:rivalFemale");
} else {
@ -406,11 +389,6 @@ export class TrainerConfig {
* @returns The updated TrainerConfig instance.
*/
setDoubleTitle(titleDouble: string): TrainerConfig {
// First check if i18n is initialized
if (!getIsInitialized()) {
initI18n();
}
titleDouble = toCamelCase(titleDouble);
// Get the title from the i18n file
@ -595,10 +573,6 @@ export class TrainerConfig {
* @returns The updated TrainerConfig instance.
*/
initForEvilTeamAdmin(title: string, poolName: EvilTeam, specialtyType?: PokemonType): TrainerConfig {
if (!getIsInitialized()) {
initI18n();
}
if (specialtyType != null) {
this.setSpecialtyType(specialtyType);
}
@ -627,10 +601,6 @@ export class TrainerConfig {
* @returns The updated TrainerConfig instance.
*/
initForStatTrainer(_isMale = false): TrainerConfig {
if (!getIsInitialized()) {
initI18n();
}
this.setPartyTemplates(trainerPartyTemplates.ELITE_FOUR);
const nameForCall = toCamelCase(this.name);
@ -659,9 +629,6 @@ export class TrainerConfig {
rematch = false,
specialtyType?: PokemonType,
): TrainerConfig {
if (!getIsInitialized()) {
initI18n();
}
if (rematch) {
this.setPartyTemplates(trainerPartyTemplates.ELITE_FOUR);
} else {
@ -703,11 +670,6 @@ export class TrainerConfig {
ignoreMinTeraWave = false,
teraSlot?: number,
): TrainerConfig {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
initI18n();
}
// Set the function to generate the Gym Leader's party template.
this.setPartyTemplateFunc(getGymLeaderPartyTemplate);
@ -760,11 +722,6 @@ export class TrainerConfig {
specialtyType?: PokemonType,
teraSlot?: number,
): TrainerConfig {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
initI18n();
}
// Set the party templates for the Elite Four.
this.setPartyTemplates(trainerPartyTemplates.ELITE_FOUR);
@ -811,11 +768,6 @@ export class TrainerConfig {
* @returns The updated TrainerConfig instance.
*/
initForChampion(isMale: boolean): TrainerConfig {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
initI18n();
}
// Set the party templates for the Champion.
this.setPartyTemplates(trainerPartyTemplates.CHAMPION);
@ -846,10 +798,6 @@ export class TrainerConfig {
* @returns The updated TrainerConfig instance.
*/
setLocalizedName(name: string): TrainerConfig {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
initI18n();
}
this.name = i18next.t(`trainerNames:${toCamelCase(name)}`);
return this;
}
@ -863,33 +811,20 @@ export class TrainerConfig {
getTitle(trainerSlot: TrainerSlot = TrainerSlot.NONE, variant: TrainerVariant): string {
const ret = this.name;
// Check if the variant is double and the name for double exists
if (!trainerSlot && variant === TrainerVariant.DOUBLE && this.nameDouble) {
return this.nameDouble;
}
// Female variant
if (this.hasGenders) {
// If the name is already set
if (this.nameFemale) {
// Check if the variant is either female or this is for the partner in a double battle
if (
variant === TrainerVariant.FEMALE
|| (variant === TrainerVariant.DOUBLE && trainerSlot === TrainerSlot.TRAINER_PARTNER)
) {
return this.nameFemale;
}
}
// Check if !variant is true, if so return the name, else return the name with _female appended
else if (variant) {
if (!getIsInitialized()) {
initI18n();
}
// Check if the female version exists in the i18n file
if (i18next.exists(`trainerClasses:${toCamelCase(this.name)}Female`)) {
// If it does, return
return ret + "Female";
}
} else if (variant && i18next.exists(`trainerClasses:${toCamelCase(this.name)}Female`)) {
return ret + "Female";
}
}

View File

@ -13,7 +13,6 @@ import { TrainerType } from "#enums/trainer-type";
import { TrainerVariant } from "#enums/trainer-variant";
import type { EnemyPokemon } from "#field/pokemon";
import type { PersistentModifier } from "#modifiers/modifier";
import { getIsInitialized, initI18n } from "#plugins/i18n";
import type { TrainerConfig } from "#trainers/trainer-config";
import { trainerConfigs } from "#trainers/trainer-config";
import { TrainerPartyCompoundTemplate, type TrainerPartyTemplate } from "#trainers/trainer-party-template";
@ -174,11 +173,6 @@ export class Trainer extends Phaser.GameObjects.Container {
if (this.name) {
// If the title should be included.
if (includeTitle) {
// Check if the internationalization (i18n) system is initialized.
if (!getIsInitialized()) {
// Initialize the i18n system if it is not already initialized.
initI18n();
}
// Get the localized trainer class name from the i18n file and set it as the title.
// This is used for trainer class names, not titles like "Elite Four, Champion, etc."
title = i18next.t(`trainerClasses:${toCamelCase(name)}`);

View File

@ -1,7 +1,7 @@
import "#app/polyfills"; // All polyfills MUST be loaded first for side effects
import "#plugins/i18n"; // Initializes i18n on import
import { InvertPostFX } from "#app/pipelines/invert";
import { initI18n } from "#app/plugins/i18n";
import { isBeta, isDev } from "#constants/app-constants";
import { version } from "#package.json";
import Phaser from "phaser";
@ -31,7 +31,6 @@ window.addEventListener("unhandledrejection", event => {
});
async function startGame(gameManifest?: Record<string, string>): Promise<void> {
await initI18n();
const LoadingScene = (await import("./loading-scene")).LoadingScene;
const BattleScene = (await import("./battle-scene")).BattleScene;
const game = new Phaser.Game({

View File

@ -4,7 +4,7 @@ import type { BattlerIndex } from "#enums/battler-index";
import { Command } from "#enums/command";
import { UiMode } from "#enums/ui-mode";
import { PokemonPhase } from "#phases/pokemon-phase";
import i18next from "#plugins/i18n";
import i18next from "i18next";
export class SelectTargetPhase extends PokemonPhase {
public readonly phaseName = "SelectTargetPhase";

View File

@ -3,7 +3,7 @@ import { toKebabCase } from "#utils/strings";
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend";
import processor, { KoreanPostpositionProcessor } from "i18next-korean-postposition-processor";
import processor from "i18next-korean-postposition-processor";
import { namespaceMap } from "./utils-plugins";
//#region Interfaces/Types
@ -16,8 +16,6 @@ interface LoadingFontFaceProperty {
//#region Constants
let isInitialized = false;
const unicodeRanges = {
fullwidth: "U+FF00-FFEF",
hangul: "U+1100-11FF,U+3130-318F,U+A960-A97F,U+AC00-D7AF,U+D7B0-D7FF",
@ -147,107 +145,90 @@ function i18nMoneyFormatter(amount: any): string {
return `@[MONEY]{${i18next.t("common:money", { amount })}}`;
}
// assigned during post-processing in #app/plugins/vite/namespaces-i18n-plugin.ts
const nsEn: string[] = [];
//#region Exports
/**
* Initialize i18n with fonts
/*
* i18next is a localization library for maintaining and using translation resources.
*
* Q: How do I add a new language?
* A: To add a new language, create a new folder in the locales directory with the language code.
* Each language folder should contain a file for each namespace (ex. menu.ts) with the translations.
* Don't forget to declare new language in `supportedLngs` i18next initializer
*
* Q: How do I add a new namespace?
* A: To add a new namespace, create a new file .json in each language folder with the translations.
* The expected format for the file-name is kebab-case {@link https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case}
* If you want the namespace name to be different from the file name, configure it in namespace-map.ts.
* Then update the config file for that language in its locale directory
* and the CustomTypeOptions interface in the @types/i18next.d.ts file.
*
* Q: How do I make a language selectable in the settings?
* A: In src/system/settings.ts, add a new case to the Setting.Language switch statement.
*/
export async function initI18n(): Promise<void> {
// Prevent reinitialization
if (isInitialized) {
return;
}
isInitialized = true;
/**
* i18next is a localization library for maintaining and using translation resources.
*
* Q: How do I add a new language?
* A: To add a new language, create a new folder in the locales directory with the language code.
* Each language folder should contain a file for each namespace (ex. menu.ts) with the translations.
* Don't forget to declare new language in `supportedLngs` i18next initializer
*
* Q: How do I add a new namespace?
* A: To add a new namespace, create a new file .json in each language folder with the translations.
* The expected format for the file-name is kebab-case {@link https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case}
* If you want the namespace name to be different from the file name, configure it in namespacemap.ts.
* Then update the config file for that language in its locale directory
* and the CustomTypeOptions interface in the @types/i18next.d.ts file.
*
* Q: How do I make a language selectable in the settings?
* A: In src/system/settings.ts, add a new case to the Setting.Language switch statement.
*/
i18next.use(HttpBackend);
i18next.use(LanguageDetector);
i18next.use(processor);
i18next.use(new KoreanPostpositionProcessor());
await i18next.init({
fallbackLng: {
"es-419": ["es-ES", "en"],
default: ["en"],
},
supportedLngs: [
"en",
"es-ES",
"es-419", // LATAM Spanish
"fr",
"it",
"de",
"zh-Hans",
"zh-Hant",
"pt-BR",
"ko",
"ja",
"ca",
"da",
"tr",
"ro",
"ru",
"tl",
"nb-NO",
],
backend: {
loadPath(lng: string, [ns]: string[]) {
// Use namespace maps where required
let fileName: string;
if (namespaceMap[ns]) {
fileName = namespaceMap[ns];
} else if (ns.startsWith("mysteryEncounters/")) {
fileName = toKebabCase(ns + "-dialogue"); // mystery-encounters/a-trainers-test-dialogue
} else {
fileName = toKebabCase(ns);
}
// ex: "./locales/en/move-anims"
return `./locales/${lng}/${fileName}.json?v=${pkg.version}`;
await i18next
.use(HttpBackend)
.use(LanguageDetector)
.use(processor)
.init(
{
fallbackLng: {
"es-419": ["es-ES", "en"],
default: ["en"],
},
supportedLngs: [
"en",
"es-ES",
"es-419", // LATAM Spanish
"fr",
"it",
"de",
"zh-Hans",
"zh-Hant",
"pt-BR",
"ko",
"ja",
"ca",
"da",
"tr",
"ro",
"ru",
"tl",
"nb-NO",
],
backend: {
loadPath(lng: string, [ns]: string[]) {
// Use namespace maps where required
let fileName: string;
if (namespaceMap[ns]) {
fileName = namespaceMap[ns];
} else if (ns.startsWith("mysteryEncounters/")) {
fileName = toKebabCase(ns + "-dialogue"); // mystery-encounters/a-trainers-test-dialogue
} else {
fileName = toKebabCase(ns);
}
// ex: "./locales/en/move-anims"
return `./locales/${lng}/${fileName}.json?v=${pkg.version}`;
},
},
defaultNS: "menu",
detection: {
lookupLocalStorage: "prLang",
},
ns: nsEn,
debug: import.meta.env.VITE_I18N_DEBUG === "1",
interpolation: {
escapeValue: false,
},
postProcess: ["korean-postposition"],
},
defaultNS: "menu",
ns: nsEn, // assigned with #app/plugins/vite/namespaces-i18n-plugin.ts
detection: {
lookupLocalStorage: "prLang",
async () => {
i18next.services.formatter?.add("money", i18nMoneyFormatter);
await initFonts(localStorage.getItem("prLang") ?? undefined);
},
debug: Number(import.meta.env.VITE_I18N_DEBUG) === 1,
interpolation: {
escapeValue: false,
},
postProcess: ["korean-postposition"],
});
if (i18next.services.formatter) {
i18next.services.formatter.add("money", i18nMoneyFormatter);
}
await initFonts(localStorage.getItem("prLang") ?? undefined);
}
export function getIsInitialized(): boolean {
return isInitialized;
}
// biome-ignore lint/style/noDefaultExport: necessary for i18next usage
export default i18next;
);
//#endregion

View File

@ -3,8 +3,8 @@ import { EggTier } from "#enums/egg-type";
import { ModifierTier } from "#enums/modifier-tier";
import { TextStyle } from "#enums/text-style";
import { UiTheme } from "#enums/ui-theme";
import i18next from "#plugins/i18n";
import type { TextStyleOptions } from "#types/ui";
import i18next from "i18next";
import type Phaser from "phaser";
import BBCodeText from "phaser3-rex-plugins/plugins/gameobjects/tagtext/bbcodetext/BBCodeText";
import type InputText from "phaser3-rex-plugins/plugins/inputtext";

View File

@ -4,8 +4,8 @@ import { SpeciesId } from "#enums/species-id";
import { UiMode } from "#enums/ui-mode";
import { CommandPhase } from "#phases/command-phase";
import { TurnInitPhase } from "#phases/turn-init-phase";
import i18next from "#plugins/i18n";
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";

View File

@ -2,9 +2,9 @@ import { modifierTypes } from "#data/data-lists";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { SpeciesStatBoosterModifier } from "#modifiers/modifier";
import i18next from "#plugins/i18n";
import { GameManager } from "#test/test-utils/game-manager";
import { NumberHolder } from "#utils/common";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

View File

@ -2,9 +2,9 @@ import { modifierTypes } from "#data/data-lists";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { SpeciesStatBoosterModifier } from "#modifiers/modifier";
import i18next from "#plugins/i18n";
import { GameManager } from "#test/test-utils/game-manager";
import { NumberHolder } from "#utils/common";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

View File

@ -2,9 +2,9 @@ import { modifierTypes } from "#data/data-lists";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { SpeciesStatBoosterModifier } from "#modifiers/modifier";
import i18next from "#plugins/i18n";
import { GameManager } from "#test/test-utils/game-manager";
import { NumberHolder } from "#utils/common";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

View File

@ -2,9 +2,9 @@ import { modifierTypes } from "#data/data-lists";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { SpeciesStatBoosterModifier } from "#modifiers/modifier";
import i18next from "#plugins/i18n";
import { GameManager } from "#test/test-utils/game-manager";
import { NumberHolder, randInt } from "#utils/common";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

View File

@ -2,8 +2,8 @@ import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import i18next from "#plugins/i18n";
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";

View File

@ -1,4 +1,6 @@
import "vitest-canvas-mock";
import "#plugins/i18n"; // tests don't go through `main.ts`, requiring this to be imported here as well
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";

View File

@ -1,6 +1,5 @@
import { SESSION_ID_COOKIE_NAME } from "#app/constants";
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 { MockConsole } from "#test/test-utils/mocks/mock-console/mock-console";
@ -15,26 +14,18 @@ import InputText from "phaser3-rex-plugins/plugins/inputtext";
let wasInitialized = false;
/**
* Run initialization code upon starting a new file, both per-suite and per-instance oncess.
* Run initialization code upon starting a new file, both per-suite and per-instance ones.
*/
export function initTests(): void {
setupStubs();
if (!wasInitialized) {
initTestFile();
initializeGame();
wasInitialized = true;
}
manageListeners();
}
/**
* Initialize various values at the beginning of each testing instance.
*/
function initTestFile(): void {
initI18n();
initializeGame();
}
/**
* Setup various stubs for testing.
* @todo Move this into a dedicated stub file instead of running it once per test instance