Add Vitest browser testing setup with GroupCard tests (#2694)

This commit is contained in:
Kalle 2026-01-04 17:35:28 +02:00 committed by GitHub
parent 73e13f7446
commit ee4f3ef4b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 770 additions and 9 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ node_modules
.react-router/
.env
translation-progress.md
**/*/__screenshots__
notes.md

View File

@ -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

18
app/browser-test-setup.ts Normal file
View File

@ -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,
});

View File

@ -7,6 +7,7 @@ export function MicrophoneIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
data-testid="microphone-icon"
>
<path
strokeLinecap="round"

View File

@ -7,6 +7,7 @@ export function SpeakerIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
data-testid="speaker-icon"
>
<path
strokeLinecap="round"

View File

@ -7,6 +7,7 @@ export function SpeakerXIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
data-testid="speaker-x-icon"
>
<path
strokeLinecap="round"

View File

@ -0,0 +1,438 @@
import type { ComponentProps } from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import type { GroupSkillDifference } from "~/db/tables";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type {
SQGroup,
SQGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
import type { TierRange } from "../q-types";
import { GroupCard } from "./GroupCard";
vi.mock("~/features/auth/core/user", () => ({
useUser: () => null,
}));
function createMember(overrides: Partial<SQGroupMember> = {}): 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<Omit<SQGroup, "members">> & {
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> = {},
): 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<Omit<SQOwnGroup, "members">> & {
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<ComponentProps<typeof GroupCard>> = {},
) {
const group = props.group ?? createGroup();
const { displayOnly = true, ...restProps } = props;
const router = createMemoryRouter(
[
{
path: "/",
element: (
<GroupCard group={group} displayOnly={displayOnly} {...restProps} />
),
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
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();
});
});
});

View File

@ -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,
},
};

View File

@ -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"
]
}

207
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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

23
vitest.browser.config.ts Normal file
View File

@ -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"],
},
});