From ee4f3ef4b97b14270986767fe8fa2b4383b394b2 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:35:28 +0200 Subject: [PATCH] Add Vitest browser testing setup with GroupCard tests (#2694) --- .github/workflows/main.yml | 5 +- .gitignore | 1 + AGENTS.md | 2 +- app/browser-test-setup.ts | 18 + app/components/icons/Microphone.tsx | 1 + app/components/icons/Speaker.tsx | 1 + app/components/icons/SpeakerX.tsx | 1 + .../components/GroupCard.browser.test.tsx | 438 ++++++++++++++++++ app/modules/i18n/resources.browser.ts | 47 ++ knip.json | 4 +- package-lock.json | 207 ++++++++- package.json | 11 +- vite.config.ts | 20 +- vitest.browser.config.ts | 23 + 14 files changed, 770 insertions(+), 9 deletions(-) create mode 100644 app/browser-test-setup.ts create mode 100644 app/features/sendouq/components/GroupCard.browser.test.tsx create mode 100644 app/modules/i18n/resources.browser.ts create mode 100644 vitest.browser.config.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b06d1e416..f73ac387a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,12 +27,15 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Playwright browsers + run: npx playwright install chromium + - name: Formatter/Linter run: npm run biome:check - name: Typecheck run: npm run typecheck - name: Unit tests - run: npm run test:unit + run: npm run test:unit:browser - name: Knip unused check run: npm run knip - name: Check translations jsons diff --git a/.gitignore b/.gitignore index 73bf74862..03de3c332 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules .react-router/ .env translation-progress.md +**/*/__screenshots__ notes.md diff --git a/AGENTS.md b/AGENTS.md index 73072bd9c..de029e770 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ - `npm run typecheck` runs TypeScript type checking - `npm run biome:fix` runs Biome code formatter and linter -- `npm run test:unit` runs all unit tests +- `npm run test:unit:browser` runs all unit tests and browser tests - `npm run test:e2e` runs all e2e tests - `npm run test:e2e:flaky-detect` runs all e2e tests and repeats each 10 times - `npm run i18n:sync` syncs translation jsons with English and should always be run after adding new text to an English translation file diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts new file mode 100644 index 000000000..d377d5e60 --- /dev/null +++ b/app/browser-test-setup.ts @@ -0,0 +1,18 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import { config } from "~/modules/i18n/config"; +import { resources } from "~/modules/i18n/resources.browser"; + +import "~/styles/common.css"; +import "~/styles/elements.css"; +import "~/styles/flags.css"; +import "~/styles/layout.css"; +import "~/styles/reset.css"; +import "~/styles/utils.css"; +import "~/styles/vars.css"; + +i18next.use(initReactI18next).init({ + ...config, + lng: "en", + resources, +}); diff --git a/app/components/icons/Microphone.tsx b/app/components/icons/Microphone.tsx index 6daa86584..bef14aa5b 100644 --- a/app/components/icons/Microphone.tsx +++ b/app/components/icons/Microphone.tsx @@ -7,6 +7,7 @@ export function MicrophoneIcon({ className }: { className?: string }) { strokeWidth={1.5} stroke="currentColor" className={className} + data-testid="microphone-icon" > ({ + useUser: () => null, +})); + +function createMember(overrides: Partial = {}): SQGroupMember { + return { + id: 1, + discordId: "123456789", + username: "TestUser", + discordAvatar: null, + customUrl: null, + role: "OWNER", + vc: "NO", + languages: [], + skill: "CALCULATING", + weapons: [], + plusTier: null, + friendCode: null, + inGameName: null, + note: null, + privateNote: null, + pronouns: null, + skillDifference: undefined, + noScreen: undefined, + chatNameColor: null, + mapModePreferences: undefined, + ...overrides, + }; +} + +function createGroup( + overrides: Partial> & { + members?: SQGroupMember[]; + } = {}, +): SQGroup { + const { members, ...rest } = overrides; + return { + id: 1, + tier: null, + tierRange: null, + skillDifference: undefined, + isReplay: false, + usersRole: null, + noScreen: false, + modePreferences: [], + status: "ACTIVE", + matchId: null, + latestActionAt: Date.now(), + members: members ?? [createMember()], + ...rest, + }; +} + +type OwnGroupMember = SQOwnGroup["members"][number]; + +function createOwnGroupMember( + overrides: Partial = {}, +): OwnGroupMember { + return { + id: 1, + discordId: "123456789", + username: "TestUser", + discordAvatar: null, + customUrl: null, + role: "OWNER", + vc: "NO", + languages: [], + skill: "CALCULATING", + weapons: [], + plusTier: null, + friendCode: null, + inGameName: null, + note: null, + privateNote: null, + pronouns: null, + skillDifference: undefined, + noScreen: undefined, + chatNameColor: null, + mapModePreferences: undefined, + ...overrides, + } satisfies OwnGroupMember; +} + +function createOwnGroup( + overrides: Partial> & { + members?: OwnGroupMember[]; + } = {}, +): SQOwnGroup { + const { members, ...rest } = overrides; + return { + id: 1, + tier: null, + tierRange: null, + skillDifference: undefined, + isReplay: false, + usersRole: "OWNER", + noScreen: false, + modePreferences: [], + chatCode: null, + status: "ACTIVE", + matchId: null, + inviteCode: "test123", + latestActionAt: Date.now(), + members: members ?? [createOwnGroupMember()], + ...rest, + }; +} + +function renderGroupCard( + props: Partial> = {}, +) { + const group = props.group ?? createGroup(); + const { displayOnly = true, ...restProps } = props; + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + ), + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +describe("GroupCard", () => { + describe("member display", () => { + test("renders single member with username", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ username: "Player1" })], + }), + }); + + await expect + .element(screen.getByTestId("sendouq-group-card")) + .toBeVisible(); + await expect + .element(screen.getByTestId("sendouq-group-card-member")) + .toBeVisible(); + await expect.element(screen.getByText("Player1")).toBeVisible(); + }); + + test("renders multiple members", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [ + createMember({ id: 1, username: "Player1" }), + createMember({ id: 2, username: "Player2", role: "MANAGER" }), + createMember({ id: 3, username: "Player3", role: "REGULAR" }), + ], + }), + }); + + await expect.element(screen.getByText("Player1")).toBeVisible(); + await expect.element(screen.getByText("Player2")).toBeVisible(); + await expect.element(screen.getByText("Player3")).toBeVisible(); + }); + + test("displays in-game name when present", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ inGameName: "IGN#1234" })], + }), + }); + + await expect.element(screen.getByText("IGN")).toBeVisible(); + }); + + test("displays calculated tier", async () => { + const skill: TieredSkill = { + ordinal: 2100, + tier: { name: "GOLD", isPlus: false }, + approximate: false, + }; + + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ skill })], + }), + }); + + // Tier info is shown in a popover button, check it renders + await expect + .element(screen.getByTestId("sendouq-group-card-member")) + .toBeVisible(); + }); + + test("displays weapons", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [ + createMember({ + weapons: [ + { weaponSplId: 40, isFavorite: 1 }, + { weaponSplId: 50, isFavorite: 0 }, + ], + }), + ], + }), + }); + + // Weapons are rendered as picture elements + const pictures = screen.container.querySelectorAll("picture"); + expect(pictures.length).toBeGreaterThan(0); + }); + }); + + describe("voice chat", () => { + test("shows microphone icon for YES", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ vc: "YES", languages: ["en"] })], + }), + }); + + await expect.element(screen.getByTestId("microphone-icon")).toBeVisible(); + }); + + test("shows speaker icon for LISTEN_ONLY", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ vc: "LISTEN_ONLY", languages: ["en"] })], + }), + }); + + await expect.element(screen.getByTestId("speaker-icon")).toBeVisible(); + }); + + test("shows speaker-x icon for NO", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ vc: "NO", languages: ["en"] })], + }), + }); + + await expect.element(screen.getByTestId("speaker-x-icon")).toBeVisible(); + }); + }); + + describe("group states", () => { + test("shows tier for full group", async () => { + const tier: TieredSkill["tier"] = { name: "PLATINUM", isPlus: true }; + + const screen = await renderGroupCard({ + group: createGroup({ + tier, + members: [ + createMember({ id: 1 }), + createMember({ id: 2, role: "REGULAR" }), + createMember({ id: 3, role: "REGULAR" }), + createMember({ id: 4, role: "REGULAR" }), + ], + }), + }); + + await expect.element(screen.getByText(/PLATINUM\+/)).toBeVisible(); + }); + + test("shows tier range", async () => { + const tierRange: TierRange = { + diff: [0, 50], + range: [ + { name: "GOLD", isPlus: false }, + { name: "PLATINUM", isPlus: true }, + ], + }; + + const screen = await renderGroupCard({ + group: createGroup({ + tierRange, + tier: null, + members: undefined, + }), + }); + + await expect + .element(screen.getByTestId("sendouq-group-card")) + .toBeVisible(); + }); + + test("shows REPLAY label when isReplay", async () => { + const tier: TieredSkill["tier"] = { name: "GOLD", isPlus: false }; + + const screen = await renderGroupCard({ + group: createGroup({ + tier, + isReplay: true, + members: [ + createMember({ id: 1 }), + createMember({ id: 2, role: "REGULAR" }), + createMember({ id: 3, role: "REGULAR" }), + createMember({ id: 4, role: "REGULAR" }), + ], + }), + }); + + // REPLAY text is rendered, translations are loaded + await expect.element(screen.getByText(/REPLAY/i)).toBeVisible(); + }); + + test("shows group skill difference", async () => { + const skillDifference: GroupSkillDifference = { + calculated: true, + oldSp: 2100, + newSp: 2150, + }; + + const screen = await renderGroupCard({ + group: createGroup({ + skillDifference, + members: undefined, + }), + }); + + await expect.element(screen.getByText(/2100/)).toBeVisible(); + await expect.element(screen.getByText(/2150/)).toBeVisible(); + }); + }); + + describe("action buttons", () => { + test("shows Invite for LIKE with members", async () => { + const ownGroup = createOwnGroup({ id: 2 }); + + const screen = await renderGroupCard({ + group: createGroup({ members: [createMember()] }), + action: "LIKE", + ownGroup, + displayOnly: false, + }); + + // Actual translated text is "Invite" + await expect.element(screen.getByText("Invite")).toBeVisible(); + }); + + test("shows Challenge for LIKE with full group (no visible members)", async () => { + const ownGroup = createOwnGroup({ id: 2 }); + + // Create a group with members explicitly set to undefined (censored/full group) + const fullGroup = createGroup({}); + fullGroup.members = undefined; + + const screen = await renderGroupCard({ + group: fullGroup, + action: "LIKE", + ownGroup, + displayOnly: false, + }); + + // Actual translated text is "Challenge" + await expect.element(screen.getByText("Challenge")).toBeVisible(); + }); + + test("shows Start Match for MATCH_UP", async () => { + const ownGroup = createOwnGroup({ id: 2 }); + + const screen = await renderGroupCard({ + group: createGroup({ members: undefined }), + action: "MATCH_UP", + ownGroup, + displayOnly: false, + }); + + // Actual translated text is "Start match" + await expect.element(screen.getByText("Start match")).toBeVisible(); + }); + + test("hides actions when user is not owner or manager", async () => { + // ownGroup with REGULAR role shouldn't show action buttons + const ownGroup = createOwnGroup({ id: 2, usersRole: "REGULAR" }); + + const screen = await renderGroupCard({ + group: createGroup({ members: [createMember()] }), + action: "LIKE", + ownGroup, + displayOnly: false, + }); + + // Action button should not be rendered when user is not OWNER or MANAGER + const actionButton = screen.container.querySelector( + '[data-testid="group-card-action-button"]', + ); + expect(actionButton).toBeNull(); + }); + }); + + describe("props", () => { + test("hides VC when hideVc=1", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [createMember({ vc: "YES", languages: ["en"] })], + }), + hideVc: 1, + }); + + await expect + .element(screen.getByTestId("sendouq-group-card-member")) + .toBeVisible(); + // VC button should not be visible, only member info + }); + + test("hides weapons when hideWeapons=1", async () => { + const screen = await renderGroupCard({ + group: createGroup({ + members: [ + createMember({ + weapons: [{ weaponSplId: 40, isFavorite: 1 }], + }), + ], + }), + hideWeapons: 1, + }); + + await expect + .element(screen.getByTestId("sendouq-group-card-member")) + .toBeVisible(); + const weaponImages = screen.getByRole("img", { name: /weapons:MAIN/ }); + await expect.element(weaponImages.first()).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/modules/i18n/resources.browser.ts b/app/modules/i18n/resources.browser.ts new file mode 100644 index 000000000..aa5a854ba --- /dev/null +++ b/app/modules/i18n/resources.browser.ts @@ -0,0 +1,47 @@ +import analyzer from "../../../locales/en/analyzer.json"; +import art from "../../../locales/en/art.json"; +import badges from "../../../locales/en/badges.json"; +import builds from "../../../locales/en/builds.json"; +import calendar from "../../../locales/en/calendar.json"; +import common from "../../../locales/en/common.json"; +import contributions from "../../../locales/en/contributions.json"; +import faq from "../../../locales/en/faq.json"; +import front from "../../../locales/en/front.json"; +import gameMisc from "../../../locales/en/game-misc.json"; +import gear from "../../../locales/en/gear.json"; +import lfg from "../../../locales/en/lfg.json"; +import org from "../../../locales/en/org.json"; +import q from "../../../locales/en/q.json"; +import scrims from "../../../locales/en/scrims.json"; +import team from "../../../locales/en/team.json"; +import tierListMaker from "../../../locales/en/tier-list-maker.json"; +import tournament from "../../../locales/en/tournament.json"; +import user from "../../../locales/en/user.json"; +import vods from "../../../locales/en/vods.json"; +import weapons from "../../../locales/en/weapons.json"; + +export const resources = { + en: { + analyzer, + art, + badges, + builds, + calendar, + common, + contributions, + faq, + front, + "game-misc": gameMisc, + gear, + lfg, + org, + q, + scrims, + team, + "tier-list-maker": tierListMaker, + tournament, + user, + vods, + weapons, + }, +}; diff --git a/knip.json b/knip.json index 6144557df..837851a6f 100644 --- a/knip.json +++ b/knip.json @@ -12,6 +12,8 @@ "scripts/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}", "public/sw-2.js", "ley.config.cjs", - "ley-driver.cjs" + "ley-driver.cjs", + "vitest.browser.config.ts", + "app/browser-test-setup.ts" ] } diff --git a/package-lock.json b/package-lock.json index b04a32804..f2942c029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/web-push": "^3.6.4", + "@vitest/browser-playwright": "^4.0.16", + "@vitest/ui": "^4.0.16", "babel-plugin-react-compiler": "^19.1.0-rc.2", "cross-env": "^10.1.0", "dotenv": "^17.2.3", @@ -92,7 +94,8 @@ "vite-node": "^5.2.0", "vite-plugin-babel": "^1.3.2", "vite-tsconfig-paths": "^6.0.3", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "vitest-browser-react": "^2.0.2" } }, "node_modules/@aws-crypto/crc32": { @@ -2814,6 +2817,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -8164,6 +8174,53 @@ "react": ">= 16.8.0" } }, + "node_modules/@vitest/browser": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.16.tgz", + "integrity": "sha512-t4toy8X/YTnjYEPoY0pbDBg3EvDPg1elCDrfc+VupPHwoN/5/FNQ8Z+xBYIaEnOE2vVEyKwqYBzZ9h9rJtZVcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.16", + "@vitest/utils": "4.0.16", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.16.tgz", + "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.16", + "@vitest/mocker": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", @@ -8275,6 +8332,35 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", + "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, + "node_modules/@vitest/ui/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/utils": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", @@ -9818,6 +9904,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -9870,6 +9963,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/flip-toolkit": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/flip-toolkit/-/flip-toolkit-7.2.4.tgz", @@ -11211,6 +11311,16 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11576,6 +11686,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -11627,6 +11750,16 @@ "node": ">=18" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13246,6 +13379,31 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sirv/node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/slugify": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", @@ -14235,6 +14393,31 @@ } } }, + "node_modules/vitest-browser-react": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.0.2.tgz", + "integrity": "sha512-zuSgTe/CKODU3ip+w4ls6Qm4xZ9+A4OHmDf0obt/mwAqavpOtqtq2YcioZt8nfDQE50EWmhdnRfDmpS1jCsbTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "vitest": "^4.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -14380,6 +14563,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 4f367c188..940fe55d7 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "biome:fix": "npx @biomejs/biome check --write .", "biome:fix:unsafe": "npx @biomejs/biome check --write --unsafe .", "typecheck": "react-router typegen && tsc --noEmit", - "test:unit": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only run", + "test:unit:browser": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 BROWSER_HEADLESS=true vitest --silent=passed-only run", + "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:e2e": "npx playwright test", "test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1", "test:e2e:generate-seeds": "cross-env DB_PATH=db-test.sqlite3 npx vite-node scripts/generate-e2e-seed-dbs.ts", - "checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck && npm run knip", + "checks": "npm run biome:fix && npm run test:unit:browser && npm run check-translation-jsons && npm run typecheck && npm run knip", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "i18n:sync": "i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && npm run biome:fix", "knip": "knip" @@ -102,6 +104,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/web-push": "^3.6.4", + "@vitest/browser-playwright": "^4.0.16", + "@vitest/ui": "^4.0.16", "babel-plugin-react-compiler": "^19.1.0-rc.2", "cross-env": "^10.1.0", "dotenv": "^17.2.3", @@ -115,6 +119,7 @@ "vite-node": "^5.2.0", "vite-plugin-babel": "^1.3.2", "vite-tsconfig-paths": "^6.0.3", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "vitest-browser-react": "^2.0.2" } } diff --git a/vite.config.ts b/vite.config.ts index dc0d82b1f..56f9eb84e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,8 +30,24 @@ export default defineConfig(({ mode }) => { tsconfigPaths(), ], test: { - exclude: [...configDefaults.exclude, "e2e/**"], - setupFiles: ["./app/test-setup.ts"], + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["**/*.test.{ts,tsx}"], + exclude: [ + ...configDefaults.exclude, + "e2e/**", + "**/*.browser.test.{ts,tsx}", + ], + setupFiles: ["./app/test-setup.ts"], + }, + }, + { + extends: "./vitest.browser.config.ts", + }, + ], }, build: { // this is mostly done so that i18n jsons as defined in ./app/modules/i18n/loader.ts diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 000000000..75a9f9d82 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,23 @@ +import { playwright } from "@vitest/browser-playwright"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +const headless = process.env.BROWSER_HEADLESS === "true"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + name: "browser", + include: ["**/*.browser.test.{ts,tsx}"], + browser: { + provider: playwright(), + enabled: true, + headless, + instances: [{ browser: "chromium" }], + }, + css: { + include: /.+/, + }, + setupFiles: ["./app/browser-test-setup.ts"], + }, +});