From 85b5c7d531a3b4753e8d5c97ccccf5bcffb8e320 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:24:34 +0200 Subject: [PATCH] Improve checks speed --- .claude/skills/db-benchmark/SKILL.md | 2 +- .github/workflows/main.yml | 4 - AGENTS.md | 2 +- app/db/write-tracker.ts | 17 ++- .../articles/core/list.server.test.ts | 10 ++ .../LeaderboardRepository.server.test.ts | 9 +- .../actions/lobbies.$id.server.test.ts | 7 +- .../mmr/SkillRepository.server.test.ts | 9 +- .../notifications/core/notify.server.test.ts | 3 +- .../actions/scrims.request.server.test.ts | 7 +- .../PlayerStatRepository.server.test.ts | 9 +- .../ReportedWeaponRepository.server.test.ts | 9 +- .../actions/q.match.$id.server.test.ts | 9 +- .../sendouq/core/default-maps.server.test.ts | 3 +- app/features/sendouq/routes/q.looking.test.ts | 6 - app/features/sendouq/routes/q.ready.test.ts | 8 +- app/features/sendouq/routes/q.test.ts | 7 +- .../settings/actions/settings.server.test.ts | 8 +- .../actions/to.$id.admin.index.server.test.ts | 10 +- .../actions/to.$id.brackets.server.test.ts | 9 +- .../actions/to.$id.looking.server.test.ts | 7 +- .../routes/to.$id.matches.$mid.test.ts | 10 +- .../actions/to.$id.register.server.test.ts | 7 +- .../closeExpiredContinueVotes.test.ts | 6 - app/routines/resolveStaleSQMatches.test.ts | 6 - app/routines/syncLiveStreams.test.ts | 22 ++-- app/test-setup.ts | 35 ++++-- .../src/content/docs/dev/architecture.mdx | 2 + package.json | 6 +- scripts/check-articles.ts | 18 --- scripts/checks.ts | 111 ++++++++++++++++++ scripts/living-docs/package-scripts.ts | 6 +- scripts/splat3-types.ts | 2 +- scripts/tsconfig.json | 5 - tsconfig.json | 2 +- vite.config.ts | 1 + vitest.unit-isolated.config.ts | 11 ++ vitest.unit.config.ts | 83 ++++++++++--- 38 files changed, 284 insertions(+), 204 deletions(-) create mode 100644 app/features/articles/core/list.server.test.ts delete mode 100644 scripts/check-articles.ts create mode 100644 scripts/checks.ts delete mode 100644 scripts/tsconfig.json create mode 100644 vitest.unit-isolated.config.ts diff --git a/.claude/skills/db-benchmark/SKILL.md b/.claude/skills/db-benchmark/SKILL.md index fd76a3a05..5c76ddb1b 100644 --- a/.claude/skills/db-benchmark/SKILL.md +++ b/.claude/skills/db-benchmark/SKILL.md @@ -73,7 +73,7 @@ async function resolveHeavyBuildUserId() { ## Verifying ```bash -pnpm run typecheck:scripts +pnpm run typecheck pnpm exec biome check --error-on-warnings --write scripts/benchmark-db.ts scripts/benchmark-db DB_PATH=db-prod.sqlite3 pnpm exec vite-node scripts/benchmark-db.ts -- --filter --iterations 3 ``` diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 65c8718b5..24a901ca9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,8 +30,6 @@ jobs: run: pnpm run biome:check - name: Typecheck run: pnpm run typecheck - - name: Typecheck scripts - run: pnpm run typecheck:scripts - name: Typecheck docs site run: pnpm run typecheck:docs - name: Unit tests @@ -46,8 +44,6 @@ jobs: run: pnpm run check-translation-sync - name: Check unused translations run: pnpm run check-unused-translations - - name: Check articles - run: pnpm run check-articles - name: Check CSS custom properties run: pnpm run check-css-vars - name: Build docs site diff --git a/AGENTS.md b/AGENTS.md index 22b0f493f..382cf7f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ ## Commands -- `pnpm run checks` runs, in order: biome:fix, unit/browser tests, translation checks (json format, plural collapse, sync with English, unused keys), article check, CSS custom property check, typecheck (app, scripts and the docs site), knip and the docs site build (with its internal link check) — knip means unused exports fail checks, so remove exports nothing imports. It also runs in production mode (`knip --production`, test code left out): an export only tests use is reported too, so delete it if production code doesn't need it, or tag it `@internal` in its JSDoc if it is a helper exported for testing (on a line of its own: `/** @internal */`, or ` * @internal` as the last line of a multi-line block) +- `pnpm run checks` ([scripts/checks.ts](./scripts/checks.ts)) first runs the steps that write files (biome:fix, translation json format, route typegen), then in parallel: unit/browser tests, typecheck (app and scripts), the docs site build (with its internal link check) and type check, knip and the translation (plural collapse, sync with English, unused keys) and CSS custom property checks. Only a failing check's output is printed — knip means unused exports fail checks, so remove exports nothing imports. It also runs in production mode (`knip --production`, test code left out): an export only tests use is reported too, so delete it if production code doesn't need it, or tag it `@internal` in its JSDoc if it is a helper exported for testing (on a line of its own: `/** @internal */`, or ` * @internal` as the last line of a multi-line block) - `pnpm run dev:docs` serves the docs site (`docs/site/`, Astro Starlight, live at https://docs.sendou.ink): the MDX docs of `docs/site/src/content/docs/` plus the living docs generated from the core modules, e2e specs and routes; `pnpm run docs:capture` refreshes its route coverage and screenshots from an e2e run, see [living-docs.mdx](./docs/site/src/content/docs/dev/living-docs.mdx) - `pnpm run typecheck` runs TypeScript type checking - `pnpm run biome:fix` runs Biome code formatter and linter diff --git a/app/db/write-tracker.ts b/app/db/write-tracker.ts index 4f8095a61..f80699af6 100644 --- a/app/db/write-tracker.ts +++ b/app/db/write-tracker.ts @@ -8,10 +8,12 @@ import type { } from "kysely"; let dirty = false; +let queried = false; -/** Tracks writes so vitest teardown wipes only after a writing test and e2e workers flush caches only after factory writes. */ +/** Tracks writes so vitest teardown wipes only after a writing test and e2e workers flush caches only after factory writes, and reads so vitest refreshes in-process caches only after a test that queried. */ export class WriteTrackerPlugin implements KyselyPlugin { transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + queried = true; if (args.node.kind !== "SelectQueryNode") { dirty = true; } @@ -41,3 +43,16 @@ export function isDatabaseDirty() { export function markDatabaseClean() { dirty = false; } + +/** + * Whether any query, reads included, has run since the last {@link markDatabaseUnqueried}. + * @internal + */ +export function isDatabaseQueried() { + return queried; +} + +/** @internal */ +export function markDatabaseUnqueried() { + queried = false; +} diff --git a/app/features/articles/core/list.server.test.ts b/app/features/articles/core/list.server.test.ts new file mode 100644 index 000000000..908833c51 --- /dev/null +++ b/app/features/articles/core/list.server.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "vitest"; +import { mostRecentArticles } from "./list.server"; + +describe("mostRecentArticles", () => { + test("parses the front matter of every article", async () => { + const articles = await mostRecentArticles(Number.POSITIVE_INFINITY); + + expect(articles.length).toBeGreaterThan(0); + }); +}); diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts index c8beedca8..a6b989955 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.test.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import { actAs } from "~/db/seed/core/actAs"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; @@ -13,13 +13,6 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as LeaderboardRepository from "./LeaderboardRepository.server"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "./leaderboards-constants"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const SEASON = Seasons.currentOrPrevious()!.nth; const SEASON_RANGE = Seasons.nthToDateRange(SEASON); const OVER_THRESHOLD = MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + 1; diff --git a/app/features/lobbies/actions/lobbies.$id.server.test.ts b/app/features/lobbies/actions/lobbies.$id.server.test.ts index 8e32a3e95..4417ff88e 100644 --- a/app/features/lobbies/actions/lobbies.$id.server.test.ts +++ b/app/features/lobbies/actions/lobbies.$id.server.test.ts @@ -1,5 +1,5 @@ import { addHours, subMinutes } from "date-fns"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as AssociationFactory from "~/db/seed/factories/AssociationFactory"; import * as LobbyFactory from "~/db/seed/factories/LobbyFactory"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; @@ -15,11 +15,6 @@ import type { lobbyActionSchema } from "../lobbies-schemas"; import { lobbySearchParams } from "../lobbies-search-params"; import { action } from "./lobbies.$id.server"; -// the progression's fire-and-forget notifications are not under test -vi.mock("~/features/notifications/core/notify.server", () => ({ - notify: () => Promise.resolve(), -})); - const lobbyAction = wrappedAction({ action }); const lobbyFormAction = wrappedAction({ action, diff --git a/app/features/mmr/SkillRepository.server.test.ts b/app/features/mmr/SkillRepository.server.test.ts index 23976c975..743f23b57 100644 --- a/app/features/mmr/SkillRepository.server.test.ts +++ b/app/features/mmr/SkillRepository.server.test.ts @@ -1,5 +1,5 @@ import { add } from "date-fns"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as SkillFactory from "~/db/seed/factories/SkillFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -9,13 +9,6 @@ import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import * as Seasons from "./core/Seasons"; import * as SkillRepository from "./SkillRepository.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const SEASON = Seasons.currentOrPrevious()!.nth; const DAY = add(Seasons.nthToDateRange(SEASON).starts, { hours: 1 }); diff --git a/app/features/notifications/core/notify.server.test.ts b/app/features/notifications/core/notify.server.test.ts index 2539f8fe9..b4f02e5dd 100644 --- a/app/features/notifications/core/notify.server.test.ts +++ b/app/features/notifications/core/notify.server.test.ts @@ -28,7 +28,8 @@ vi.mock("./webPush.server", () => ({ }, })); -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ +vi.mock("~/features/chat/ChatSystemMessage.server", async (importOriginal) => ({ + ...(await importOriginal()), notifyNotificationsChanged: vi.fn(), })); diff --git a/app/features/scrims/actions/scrims.request.server.test.ts b/app/features/scrims/actions/scrims.request.server.test.ts index 265a42c0a..21420b850 100644 --- a/app/features/scrims/actions/scrims.request.server.test.ts +++ b/app/features/scrims/actions/scrims.request.server.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { wrappedAction } from "~/utils/Test"; @@ -6,11 +6,6 @@ import * as ScrimPostRepository from "../ScrimPostRepository.server"; import type { scrimRequestFormSchema } from "../scrims-schemas"; import { action as scrimsAction } from "./scrims.server"; -// the action's fire-and-forget notification is not under test -vi.mock("~/features/notifications/core/notify.server", () => ({ - notify: () => Promise.resolve(), -})); - const requestScrim = wrappedAction({ action: scrimsAction, isJsonSubmission: true, diff --git a/app/features/sendouq-match/PlayerStatRepository.server.test.ts b/app/features/sendouq-match/PlayerStatRepository.server.test.ts index f4988660a..dce6e2671 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.test.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.test.ts @@ -1,5 +1,5 @@ import { add } from "date-fns"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as MapResultFactory from "~/db/seed/factories/MapResultFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -7,13 +7,6 @@ import * as Seasons from "~/features/mmr/core/Seasons"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import * as PlayerStatRepository from "./PlayerStatRepository.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const SEASON = Seasons.currentOrPrevious()!.nth; const DAY = add(Seasons.nthToDateRange(SEASON).starts, { hours: 1 }); diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts index 45c49e073..e8112a2f1 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; @@ -12,13 +12,6 @@ import { invariant } from "~/utils/invariant"; import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server"; import * as SQMatchRepository from "./SQMatchRepository.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const SEASON = Seasons.currentOrPrevious()!.nth; const WEAPON_SPL_ID = 40; diff --git a/app/features/sendouq-match/actions/q.match.$id.server.test.ts b/app/features/sendouq-match/actions/q.match.$id.server.test.ts index b3c7b9702..dbba2e52b 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.test.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import type { reportWeaponSchema } from "~/components/match-page/match-page-schemas"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -8,13 +8,6 @@ import * as ReportedWeaponRepository from "../ReportedWeaponRepository.server"; import * as SQMatchRepository from "../SQMatchRepository.server"; import { action as rawMatchAction } from "./q.match.$id.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const WEAPON_SPL_ID = 40; const matchAction = wrappedAction({ diff --git a/app/features/sendouq/core/default-maps.server.test.ts b/app/features/sendouq/core/default-maps.server.test.ts index efcf0bbf9..01dc6ea35 100644 --- a/app/features/sendouq/core/default-maps.server.test.ts +++ b/app/features/sendouq/core/default-maps.server.test.ts @@ -15,7 +15,8 @@ const { mockSeasonCurrent, mockSeasonPrevious } = vi.hoisted(() => ({ mockSeasonPrevious: vi.fn(), })); -vi.mock("~/features/mmr/core/Seasons", () => ({ +vi.mock("~/features/mmr/core/Seasons", async (importOriginal) => ({ + ...(await importOriginal()), current: mockSeasonCurrent, previous: mockSeasonPrevious, })); diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts index 969a7c437..9b1f72415 100644 --- a/app/features/sendouq/routes/q.looking.test.ts +++ b/app/features/sendouq/routes/q.looking.test.ts @@ -15,12 +15,6 @@ import type { lookingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE } from "../q-constants"; import { action as rawLookingAction } from "./q.looking"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), -})); - const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [ { mode: "SZ", preference: "PREFER" }, { mode: "TC", preference: "AVOID" }, diff --git a/app/features/sendouq/routes/q.ready.test.ts b/app/features/sendouq/routes/q.ready.test.ts index 6a97c531c..42f5992f4 100644 --- a/app/features/sendouq/routes/q.ready.test.ts +++ b/app/features/sendouq/routes/q.ready.test.ts @@ -1,5 +1,5 @@ import { subMinutes } from "date-fns"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { backdate } from "~/db/seed/core/backdate"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -15,12 +15,6 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; import { pinClockInsideSeason } from "../tests/season-clock"; import { action as rawReadyAction, loader as rawReadyLoader } from "./q.ready"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), -})); - const readyLoader = wrappedLoader>>({ loader: rawReadyLoader, }); diff --git a/app/features/sendouq/routes/q.test.ts b/app/features/sendouq/routes/q.test.ts index 3b24c1d7d..66194afd2 100644 --- a/app/features/sendouq/routes/q.test.ts +++ b/app/features/sendouq/routes/q.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; @@ -9,11 +9,6 @@ import type { frontPageSchema } from "../q-action-schemas"; import { sendouQInviteLink } from "../q-urls"; import { action as rawFrontPageAction } from "./q"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), -})); - const frontPageAction = wrappedAction({ action: rawFrontPageAction, }); diff --git a/app/features/settings/actions/settings.server.test.ts b/app/features/settings/actions/settings.server.test.ts index fe9f7ff3a..c083fbc6f 100644 --- a/app/features/settings/actions/settings.server.test.ts +++ b/app/features/settings/actions/settings.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server"; @@ -11,12 +11,6 @@ import { wrappedAction } from "~/utils/Test"; import type { settingsActionSchema } from "../settings-schemas.server"; import { action } from "./settings.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), -})); - const OLD_WEAPON_ID: MainWeaponId = 0; const NEW_WEAPON_ID: MainWeaponId = 40; diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.test.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.test.ts index 9b5c9c52c..6db1a36a9 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.index.server.test.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -7,14 +7,6 @@ import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { adminTeamsActionSchema } from "../tournament-admin-schemas"; import { action } from "./to.$id.admin.index.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const adminTeamsAction = wrappedAction({ action, isJsonSubmission: true, diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts index 815446916..c58342a30 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -12,13 +12,6 @@ import { invariant } from "~/utils/invariant"; import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import { action } from "./to.$id.brackets.server"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const bracketsAction = wrappedAction({ action, isJsonSubmission: true, diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts index d4e94e11d..6c610410d 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts @@ -1,5 +1,5 @@ import { addDays, subDays } from "date-fns"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentLFGTeamFactory from "~/db/seed/factories/TournamentLFGTeamFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; @@ -10,11 +10,6 @@ import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; import type { lookingSchema } from "../tournament-lfg-schemas"; import { action as lookingAction } from "./to.$id.looking.server"; -// likes and accepts notify the other group; not under test here -vi.mock("~/features/notifications/core/notify.server", () => ({ - notify: () => Promise.resolve(), -})); - const looking = wrappedAction({ action: lookingAction }); const users = UserFactory.pool(); diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 6390e14b8..f2103a062 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -1,5 +1,5 @@ import type * as v from "valibot"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -14,14 +14,6 @@ import { } from "~/utils/Test"; import { action, loader } from "./to.$id.matches.$mid"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - sendPersisted: vi.fn(), - notifyNotificationsChanged: vi.fn(), - notifyRoomsChangedByRoomIds: vi.fn(), -})); - const tournamentMatchAction = wrappedAction({ action, isJsonSubmission: true, diff --git a/app/features/tournament/actions/to.$id.register.server.test.ts b/app/features/tournament/actions/to.$id.register.server.test.ts index f09709c85..1eb6130e4 100644 --- a/app/features/tournament/actions/to.$id.register.server.test.ts +++ b/app/features/tournament/actions/to.$id.register.server.test.ts @@ -1,5 +1,5 @@ import { addDays, addMinutes, subMinutes } from "date-fns"; -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import * as FriendshipFactory from "~/db/seed/factories/FriendshipFactory"; import * as LobbyFactory from "~/db/seed/factories/LobbyFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; @@ -12,11 +12,6 @@ import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { registerSchema } from "../tournament-schemas.server"; import { action as registerAction } from "./to.$id.register.server"; -// adding a player notifies them; not under test here -vi.mock("~/features/notifications/core/notify.server", () => ({ - notify: () => Promise.resolve(), -})); - /** `maxMembersPerTeam` of a regular 4v4 tournament that sets no limit of its own. */ const MAX_ROSTER_SIZE = 6; diff --git a/app/routines/closeExpiredContinueVotes.test.ts b/app/routines/closeExpiredContinueVotes.test.ts index af4c0bad2..968bf830a 100644 --- a/app/routines/closeExpiredContinueVotes.test.ts +++ b/app/routines/closeExpiredContinueVotes.test.ts @@ -7,12 +7,6 @@ import { db } from "~/db/sql"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), -})); - const users = UserFactory.pool(); /** The two SendouQ groups: the first FULL_GROUP_SIZE users against the rest. */ const alphaUserIds = () => users.ids().slice(0, FULL_GROUP_SIZE); diff --git a/app/routines/resolveStaleSQMatches.test.ts b/app/routines/resolveStaleSQMatches.test.ts index 93969dbd7..f4638d95f 100644 --- a/app/routines/resolveStaleSQMatches.test.ts +++ b/app/routines/resolveStaleSQMatches.test.ts @@ -7,12 +7,6 @@ import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { ResolveStaleSQMatchesRoutine } from "./resolveStaleSQMatches"; -vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ - send: vi.fn(), - notifyStatusChanged: vi.fn(), - notifyNotificationsChanged: vi.fn(), -})); - vi.mock("~/features/mmr/tiered.server", () => ({ refreshUserSkills: vi.fn(), })); diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts index cc0a43ad3..8b5272c52 100644 --- a/app/routines/syncLiveStreams.test.ts +++ b/app/routines/syncLiveStreams.test.ts @@ -94,13 +94,21 @@ async function addRunningTournament({ let timeOffset = 0; +// every test moves the clock past the tournament stream sync throttle, whatever order they run in +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime( + add(new Date("2025-01-15T12:00:00Z"), { minutes: timeOffset }), + ); + timeOffset += 31; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + describe("syncLiveStreams tournament streamers", () => { beforeEach(async () => { - vi.useFakeTimers(); - vi.setSystemTime( - add(new Date("2025-01-15T12:00:00Z"), { minutes: timeOffset }), - ); - timeOffset += 31; RunningTournaments.clear(); mockGetStreams.mockReset(); mockGetStreams.mockResolvedValue([]); @@ -109,10 +117,6 @@ describe("syncLiveStreams tournament streamers", () => { await users.create(1); }); - afterEach(() => { - vi.useRealTimers(); - }); - test("populates LiveStream table with streams", async () => { mockGetStreams.mockResolvedValue([ { diff --git a/app/test-setup.ts b/app/test-setup.ts index 0d37f1b61..ea19457a8 100644 --- a/app/test-setup.ts +++ b/app/test-setup.ts @@ -1,15 +1,36 @@ -import { afterEach, vi } from "vitest"; -import { isDatabaseDirty } from "~/db/write-tracker"; +import { afterAll, afterEach, vi } from "vitest"; +import { + isDatabaseDirty, + isDatabaseQueried, + markDatabaseUnqueried, +} from "~/db/write-tracker"; -// wipes the database after any test that wrote to it; the dynamic import keeps -// tests that never touch the database from opening the connection +// wipes the database after any test that wrote to it and drops the in-process state read from it; +// the dynamic imports keep tests that never touch the database from opening the connection afterEach(async () => { - if (!isDatabaseDirty()) return; + if (isDatabaseDirty()) { + const { dbReset } = await import("~/db/reset"); + await dbReset(); + } - const { dbReset } = await import("~/db/reset"); - await dbReset(); + if (!isDatabaseQueried()) return; + + const { refreshCaches } = await import( + "~/features/api-private/core/refresh-caches.server" + ); + const { cache } = await import("~/utils/cache.server"); + + await refreshCaches(); + // refreshing warms lazy caches from the wiped database, the next test's writes would find them stale + cache.clear(); + markDatabaseUnqueried(); }, 30_000); +// the unit project shares the module cache across files, so a file's fake timers would outlive it +afterAll(() => { + vi.useRealTimers(); +}); + // avoids a "Cannot find module '@aws-sdk/core/dist-es/submodules/client/index'" error, delete if tests pass without vi.mock("@aws-sdk/client-s3", () => ({ diff --git a/docs/site/src/content/docs/dev/architecture.mdx b/docs/site/src/content/docs/dev/architecture.mdx index ed2bffd5c..1b35659f5 100644 --- a/docs/site/src/content/docs/dev/architecture.mdx +++ b/docs/site/src/content/docs/dev/architecture.mdx @@ -139,6 +139,8 @@ Unit testing "core logic" (i.e. no React, no DB calls) with Vitest is highly enc Vitest can also be used to write "integration tests" that call actions/loaders directly via the `wrappedAction` / `wrappedLoader` helpers from `~/utils/Test` (see `t.$customUrl.edit.test.ts` for example). These run against `db-test.sqlite3` and reset it between tests with `dbReset`. In practice this is best sparingly as they are typically slower than pure unit tests with more dependencies but also don't test the true end to end flow. Repository functions are tested the same way (`*Repository.server.test.ts`), wrapping calls in `withUserId` / `withNoUser` when the repository resolves an acting user. +To keep the suite fast, unit test files share one module cache per worker (`isolate: false`): after every test that queried the database the in-process caches are refreshed (`app/test-setup.ts`), and spies, stubbed envs and fake timers are restored. A mock made with `vi.mock` would leak into the next file though, so any file that calls it is detected and runs in the isolated `unit-isolated` project instead, where each file re-imports everything and so runs noticeably slower. Reach for `vi.mock` only when the test can't be written against the real module. + Components can be tested in a real browser with Vitest browser mode. These files are named `*.browser.test.tsx` and run together with the unit tests via `pnpm run test:unit:browser`. Which brings us to E2E tests. For new features at least testing the happy path is encouraged. For more critical features (mainly tournament related stuff) it makes sense to test a bit more rigorously. diff --git a/package.json b/package.json index 3650af5fb..95675e88c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "migrate:sandbox": "cross-env DB_PATH=db-sandbox.sqlite3 pnpm run migrate up", "check-translation-jsons": "node scripts/check-translation-jsons.ts", "check-translation-jsons:no-write": "node scripts/check-translation-jsons.ts --no-write", - "check-articles": "vite-node scripts/check-articles.ts", "check-css-vars": "node scripts/check-css-vars.ts", "bench:db": "cross-env DB_PATH=db-sandbox.sqlite3 VITE_PROD_MODE=true vite-node scripts/benchmark-db.ts", "compute-luti-divs": "vite-node scripts/compute-luti-divs.ts", @@ -32,9 +31,8 @@ "biome:fix": "biome check --error-on-warnings --write .", "biome:fix:unsafe": "biome check --error-on-warnings --write --unsafe .", "typecheck": "react-router typegen && tsc --noEmit", - "typecheck:scripts": "tsc --noEmit -p scripts", "typecheck:docs": "astro sync --root docs/site && tsc --noEmit -p docs/site", - "test:unit:browser": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 BROWSER_HEADLESS=true vitest --silent=passed-only run --project unit --project browser", + "test:unit:browser": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 BROWSER_HEADLESS=true vitest --silent=passed-only run --project unit --project unit-isolated --project browser", "test:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only --project browser", "test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only", "test:scanner": "vitest run --project scanner", @@ -57,7 +55,7 @@ "check-plural-collapse": "node scripts/collapse-single-plural-keys.ts --check", "check-translation-sync": "node scripts/sync-translation-jsons.ts --check", "check-unused-translations": "node scripts/generate-translation-usage.ts --check-unused", - "checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run check-plural-collapse && pnpm run check-translation-sync && pnpm run check-unused-translations && pnpm run check-articles && pnpm run check-css-vars && pnpm run typecheck && pnpm run typecheck:scripts && pnpm run typecheck:docs && pnpm run knip && pnpm run docs:build", + "checks": "node scripts/checks.ts", "checks:scanner": "pnpm checks && pnpm test:scanner", "seed": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/seed.ts", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", diff --git a/scripts/check-articles.ts b/scripts/check-articles.ts deleted file mode 100644 index aa6192c59..000000000 --- a/scripts/check-articles.ts +++ /dev/null @@ -1,18 +0,0 @@ -// validates all articles files in the project are following the correct .md + grey matter format - -import { mostRecentArticles } from "~/features/articles/core/list.server"; -import { logger } from "~/utils/logger"; - -async function main() { - await mostRecentArticles(1000); -} - -main() - .then(() => { - logger.info("Articles are valid."); - process.exit(0); - }) - .catch((error) => { - logger.error("Error validating articles:", error); - process.exit(1); - }); diff --git a/scripts/checks.ts b/scripts/checks.ts new file mode 100644 index 000000000..83f8101b6 --- /dev/null +++ b/scripts/checks.ts @@ -0,0 +1,111 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ + +// `pnpm run checks`: runs the steps that write files one by one, then every read-only check at +// once. A check's output is held back and only printed when it fails + +import { spawn } from "node:child_process"; +import { styleText } from "node:util"; + +/** Run in order before the checks, they rewrite files the checks read. */ +const WRITING_STEPS: Check[] = [ + { name: "biome", command: "pnpm run biome:fix" }, + { name: "translation jsons", command: "pnpm run check-translation-jsons" }, + { name: "route types", command: "react-router typegen" }, +]; + +/** Run in parallel, none of them writes a file another one reads. */ +const CHECKS: Check[] = [ + { name: "unit & browser tests", command: "pnpm run test:unit:browser" }, + { name: "typecheck", command: "tsc --noEmit" }, + { + name: "docs site", + // the build generates the content types the docs type check reads + command: "pnpm run docs:build && tsc --noEmit -p docs/site", + }, + { name: "knip", command: "pnpm run knip" }, + { + name: "translations & css vars", + command: [ + "pnpm run check-plural-collapse", + "pnpm run check-translation-sync", + "pnpm run check-unused-translations", + "pnpm run check-css-vars", + ].join(" && "), + }, +]; + +interface Check { + name: string; + command: string; +} + +interface CheckResult extends Check { + ok: boolean; + output: string; +} + +const startedAt = performance.now(); + +for (const step of WRITING_STEPS) { + const result = await run(step); + if (!result.ok) { + printFailure(result); + process.exit(1); + } +} + +const results = await Promise.all(CHECKS.map(run)); +const failed = results.filter((result) => !result.ok); + +for (const result of failed) { + printFailure(result); +} + +console.log( + failed.length === 0 + ? styleText("green", `All checks passed in ${seconds(startedAt)}`) + : styleText( + "red", + `${failed.length} of ${CHECKS.length + WRITING_STEPS.length} checks failed: ${failed.map((result) => result.name).join(", ")}`, + ), +); +process.exit(failed.length === 0 ? 0 : 1); + +function run(check: Check): Promise { + const checkStartedAt = performance.now(); + const child = spawn(check.command, { + shell: true, + env: { ...process.env, FORCE_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + + return new Promise((resolve) => { + child.on("close", (code) => { + const ok = code === 0; + console.log( + `${ok ? styleText("green", "✓") : styleText("red", "✗")} ${check.name} ${styleText("dim", seconds(checkStartedAt))}`, + ); + resolve({ ...check, ok, output }); + }); + }); +} + +function printFailure(result: CheckResult) { + console.log( + `\n${styleText(["red", "bold"], `── ${result.name} failed (${result.command}) ──`)}\n`, + ); + console.log(result.output.trimEnd()); + console.log(); +} + +function seconds(since: number) { + return `${((performance.now() - since) / 1000).toFixed(1)}s`; +} diff --git a/scripts/living-docs/package-scripts.ts b/scripts/living-docs/package-scripts.ts index f1db42d4d..aa82b75c5 100644 --- a/scripts/living-docs/package-scripts.ts +++ b/scripts/living-docs/package-scripts.ts @@ -47,8 +47,8 @@ const DESCRIPTIONS: Record = { "biome:check": "Biome lint and format check", "biome:fix": "Biome lint and format, applying the safe fixes", "biome:fix:unsafe": "Biome lint and format, applying the unsafe fixes too", - typecheck: "Type checks the app after generating the route types", - "typecheck:scripts": "Type checks scripts/", + typecheck: + "Type checks the app and scripts/ after generating the route types", "typecheck:docs": "Type checks the docs site", "test:unit:browser": "Runs the unit and browser (Vitest browser mode) tests", "test:browser:ui": "Browser tests in Vitest's UI", @@ -64,7 +64,7 @@ const DESCRIPTIONS: Record = { "check-unused-translations": "Fails when an English translation key is used nowhere in app/, matched like the `i18n:usage` index", checks: - "Everything CI checks: lint, unit and browser tests, translations, type checks and knip. A task isn't done until it passes", + "Everything CI checks: lint, unit and browser tests, translations, type checks, knip and the docs build, the read-only ones in parallel. A task isn't done until it passes", "checks:scanner": "`checks` plus the scanner tests", seed: "Rebuilds the dev dataset of db.sqlite3", setup: diff --git a/scripts/splat3-types.ts b/scripts/splat3-types.ts index a4521b9c5..8cd184c68 100644 --- a/scripts/splat3-types.ts +++ b/scripts/splat3-types.ts @@ -1,6 +1,6 @@ /** * Hand-maintained types for the [Leanny/splat3](https://github.com/Leanny/splat3) game data dump, - * declared rather than inferred so `pnpm run typecheck:scripts` passes without the gitignored dump in + * declared rather than inferred so `pnpm run typecheck` passes without the gitignored dump in * `scripts/dicts/splat3`. Only the fields the scripts read are declared; update alongside the scripts. */ diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json deleted file mode 100644 index 4719c9c7b..000000000 --- a/scripts/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["./**/*.ts", "../types/**/*.d.ts"], - "exclude": [] -} diff --git a/tsconfig.json b/tsconfig.json index e64409816..0bb9e7e63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx", ".react-router/types/**/*"], - "exclude": ["scripts/**/*", "docs/**/*"], + "exclude": ["docs/**/*"], "compilerOptions": { "types": ["@react-router/node", "vite/client"], "rootDirs": [".", "./.react-router/types"], diff --git a/vite.config.ts b/vite.config.ts index acacbe462..e0b53e4ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -66,6 +66,7 @@ export default defineConfig((config) => { globalSetup: ["./scripts/ensure-test-db.ts"], projects: [ "./vitest.unit.config.ts", + "./vitest.unit-isolated.config.ts", "./vitest.browser.config.ts", "./vitest.scanner.config.ts", ], diff --git a/vitest.unit-isolated.config.ts b/vitest.unit-isolated.config.ts new file mode 100644 index 000000000..ff5627ca3 --- /dev/null +++ b/vitest.unit-isolated.config.ts @@ -0,0 +1,11 @@ +import { + MODULE_MOCKING_TEST_FILES, + unitProjectConfig, +} from "./vitest.unit.config"; + +export default unitProjectConfig({ + name: "unit-isolated", + include: MODULE_MOCKING_TEST_FILES, + exclude: [], + isolate: true, +}); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 8fbcddb4a..5fc17fc7e 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -1,22 +1,67 @@ +import fs from "node:fs"; import { configDefaults, defineConfig } from "vitest/config"; -export default defineConfig({ - define: { - __GIT_COMMIT__: JSON.stringify(""), - }, - test: { - name: "unit", - include: ["**/*.test.{ts,tsx}"], - exclude: [ - ...configDefaults.exclude, - "e2e/**", - "**/*.browser.test.{ts,tsx}", - // golden-file suites live in vitest.scanner.config.ts; tests/logic/ needs no fixtures so stays here - "app/features/scanner/tests/*.test.{ts,tsx}", - ], - setupFiles: ["./app/test-setup.ts"], - }, - resolve: { - tsconfigPaths: true, - }, +const UNIT_TEST_GLOB = "**/*.test.{ts,tsx}"; + +const UNIT_TEST_EXCLUDE = [ + ...configDefaults.exclude, + "e2e/**", + "**/*.browser.test.{ts,tsx}", + // golden-file suites live in vitest.scanner.config.ts; tests/logic/ needs no fixtures so stays here + "app/features/scanner/tests/*.test.{ts,tsx}", +]; + +/** + * Test files that replace modules with `vi.mock`. With the module cache shared across files + * a mock would leak into (or miss) other files, so these run in the isolated project of + * `vitest.unit-isolated.config.ts` instead. + */ +export const MODULE_MOCKING_TEST_FILES = fs + .globSync(UNIT_TEST_GLOB, { + exclude: (file) => + file === "node_modules" || file === ".git" || file === "build", + }) + .filter((file) => !file.includes(".browser.test.")) + .filter((file) => + /\bvi\.(mock|doMock)\(/.test(fs.readFileSync(file, "utf8")), + ); + +/** Config shared by the unit and unit-isolated projects. */ +export function unitProjectConfig({ + name, + include, + exclude, + isolate, +}: { + name: string; + include: string[]; + exclude: string[]; + isolate: boolean; +}) { + return defineConfig({ + define: { + __GIT_COMMIT__: JSON.stringify(""), + }, + test: { + name, + include, + exclude: [...UNIT_TEST_EXCLUDE, ...exclude], + setupFiles: ["./app/test-setup.ts"], + // re-evaluating every file's module graph took ~85% of the run, so files share the cache + isolate, + restoreMocks: true, + unstubEnvs: true, + unstubGlobals: true, + }, + resolve: { + tsconfigPaths: true, + }, + }); +} + +export default unitProjectConfig({ + name: "unit", + include: [UNIT_TEST_GLOB], + exclude: MODULE_MOCKING_TEST_FILES, + isolate: false, });