This commit is contained in:
Kalle 2026-03-19 17:39:19 +02:00
parent db71c4d01e
commit 5deeeab4ee
43 changed files with 886 additions and 56 deletions

View File

@ -148,6 +148,8 @@
flex: 1;
overflow-y: auto;
padding: var(--s-2);
display: flex;
flex-direction: column;
}
.panelDialog {
@ -316,12 +318,14 @@
align-items: center;
gap: 2px;
width: fit-content;
margin: var(--s-4) auto;
font-size: var(--font-2xs);
margin-top: auto;
margin-inline: auto;
margin-bottom: var(--s-4);
font-size: var(--font-xs);
color: var(--color-text-high);
text-decoration: none;
height: var(--selector-size);
padding: 0 var(--s-3);
height: var(--field-size);
padding: 0 var(--s-4);
background-color: var(--color-bg-high);
border-radius: var(--radius-selector);

View File

@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { SendouToastRegion, toastQueue } from "./Toast";
function Wrapper() {
return <SendouToastRegion />;
}
describe("Toast", () => {
test("renders success toast", async () => {
const screen = await render(<Wrapper />);
toastQueue.add(
{ message: "Operation completed", variant: "success" },
{ timeout: 5000 },
);
await expect.element(screen.getByText("Operation completed")).toBeVisible();
});
test("renders error toast", async () => {
const screen = await render(<Wrapper />);
toastQueue.add({ message: "Something went wrong", variant: "error" });
await expect
.element(screen.getByText("Something went wrong"))
.toBeVisible();
});
test("dismisses toast when close button is clicked", async () => {
const screen = await render(<Wrapper />);
toastQueue.add({ message: "Dismiss me", variant: "info" }, { timeout: 0 });
const toast = screen.getByRole("alertdialog", { name: "Dismiss me" });
await expect.element(toast).toBeVisible();
await screen.getByLabelText("Close").first().click();
await expect.element(toast).not.toBeInTheDocument();
});
});

View File

@ -3,7 +3,7 @@
gap: 8px;
display: flex;
position: fixed;
top: var(--layout-nav-height);
top: calc(var(--layout-nav-height) + var(--s-2));
right: 10px;
z-index: 10;
}

View File

@ -9,6 +9,7 @@ import {
} from "react-aria-components";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { SendouButton } from "./Button";
import styles from "./Toast.module.css";
@ -33,7 +34,10 @@ export function SendouToastRegion() {
const { t } = useTranslation(["common"]);
return (
<ToastRegion queue={toastQueue} className={styles.toastRegion}>
<ToastRegion
queue={toastQueue}
className={clsx(styles.toastRegion, { hidden: IS_E2E_TEST_RUN })}
>
{({ toast }) => (
<Toast
style={{ viewTransitionName: toast.key }}

View File

@ -329,6 +329,7 @@ export function Layout({
<SideNavCollapseButton
className={styles.sideNavModalTrigger}
showNotificationDot={!sideNavModalOpen && unseenIds.length > 0}
testId="sidenav-modal-trigger"
/>
<ModalOverlay className={styles.sideNavModalOverlay} isDismissable>
<Modal className={styles.sideNavModal}>
@ -364,6 +365,7 @@ export function Layout({
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
showNotificationDot={sideNavCollapsed && unseenIds.length > 0}
testId="sidenav-collapse-button"
/>
<TopNavMenus />
<TopRightButtons
@ -460,13 +462,15 @@ function SideNavCollapseButton({
onToggle,
className,
showNotificationDot,
testId,
}: {
onToggle?: () => void;
className?: string;
showNotificationDot?: boolean;
testId?: string;
}) {
return (
<div className={styles.sideNavCollapseButtonContainer}>
<div className={styles.sideNavCollapseButtonContainer} data-testid={testId}>
<SendouButton
className={className}
variant="minimal"

View File

@ -211,6 +211,7 @@ export function Chat({
isDisabled={sendingMessagesDisabled}
aria-label={t("common:chat.send")}
icon={<SendHorizontal size={16} />}
testId="chat-submit-button"
/>
</div>
</form>

View File

@ -0,0 +1,465 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as FriendRepository from "./FriendRepository.server";
const createFriendRequest = async ({
senderId,
receiverId,
}: {
senderId: number;
receiverId: number;
}) => {
await FriendRepository.insertFriendRequest({ senderId, receiverId });
const request = await FriendRepository.findFriendRequestBetween({
senderId,
receiverId,
});
return request!.id;
};
const createFriendship = async ({
senderId,
receiverId,
}: {
senderId: number;
receiverId: number;
}) => {
const requestId = await createFriendRequest({ senderId, receiverId });
await FriendRepository.insertFriendship({
userOneId: senderId,
userTwoId: receiverId,
friendRequestId: requestId,
});
};
describe("insertFriendRequest / findFriendRequestBetween", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("finds request from sender to receiver", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestBetween({
senderId: 1,
receiverId: 2,
});
expect(result).toBeDefined();
expect(result!.id).toBeTypeOf("number");
});
test("finds request in reverse direction", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestBetween({
senderId: 2,
receiverId: 1,
});
expect(result).toBeDefined();
});
test("returns undefined for unrelated users", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestBetween({
senderId: 1,
receiverId: 3,
});
expect(result).toBeUndefined();
});
});
describe("findPendingSentRequests / findPendingReceivedRequests", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("sent request appears in sender's sent requests", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findPendingSentRequests(1);
expect(result).toHaveLength(1);
expect(result[0].receiverId).toBe(2);
});
test("sent request appears in receiver's received requests", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findPendingReceivedRequests(2);
expect(result).toHaveLength(1);
expect(result[0].senderId).toBe(1);
});
test("does not appear in wrong user's requests", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
const sent = await FriendRepository.findPendingSentRequests(3);
const received = await FriendRepository.findPendingReceivedRequests(3);
expect(sent).toHaveLength(0);
expect(received).toHaveLength(0);
});
});
describe("countPendingSentRequests", () => {
beforeEach(async () => {
await dbInsertUsers(4);
});
afterEach(() => {
dbReset();
});
test("returns 0 with no requests", async () => {
const count = await FriendRepository.countPendingSentRequests(1);
expect(count).toBe(0);
});
test("returns correct count after inserting multiple requests", async () => {
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 2,
});
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 3,
});
await FriendRepository.insertFriendRequest({
senderId: 1,
receiverId: 4,
});
const count = await FriendRepository.countPendingSentRequests(1);
expect(count).toBe(3);
});
});
describe("deleteFriendRequest", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("deletes request by sender", async () => {
const requestId = await createFriendRequest({
senderId: 1,
receiverId: 2,
});
await FriendRepository.deleteFriendRequest({ id: requestId, senderId: 1 });
const result = await FriendRepository.findFriendRequestBetween({
senderId: 1,
receiverId: 2,
});
expect(result).toBeUndefined();
});
test("does not delete when wrong senderId is used", async () => {
const requestId = await createFriendRequest({
senderId: 1,
receiverId: 2,
});
await FriendRepository.deleteFriendRequest({ id: requestId, senderId: 3 });
const result = await FriendRepository.findFriendRequestBetween({
senderId: 1,
receiverId: 2,
});
expect(result).toBeDefined();
});
});
describe("deleteFriendRequestByReceiver", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("deletes request by receiver", async () => {
const requestId = await createFriendRequest({
senderId: 1,
receiverId: 2,
});
await FriendRepository.deleteFriendRequestByReceiver({
id: requestId,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestBetween({
senderId: 1,
receiverId: 2,
});
expect(result).toBeUndefined();
});
});
describe("insertFriendship / findFriendship / findFriendIds", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("creates friendship and removes friend request", async () => {
const requestId = await createFriendRequest({
senderId: 2,
receiverId: 1,
});
await FriendRepository.insertFriendship({
userOneId: 2,
userTwoId: 1,
friendRequestId: requestId,
});
const friendship = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 2,
});
expect(friendship).toBeDefined();
const pendingRequest = await FriendRepository.findFriendRequestBetween({
senderId: 2,
receiverId: 1,
});
expect(pendingRequest).toBeUndefined();
});
test("normalizes IDs so userOneId < userTwoId", async () => {
const requestId = await createFriendRequest({
senderId: 3,
receiverId: 1,
});
await FriendRepository.insertFriendship({
userOneId: 3,
userTwoId: 1,
friendRequestId: requestId,
});
const friendship = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 3,
});
expect(friendship).toBeDefined();
});
test("findFriendIds returns friend's ID", async () => {
await createFriendship({ senderId: 1, receiverId: 2 });
const friendIds = await FriendRepository.findFriendIds(1);
expect(friendIds).toHaveLength(1);
expect(friendIds).toContain(2);
});
test("findFriendIds returns friend ID from both sides", async () => {
await createFriendship({ senderId: 1, receiverId: 2 });
const friendIdsOfUser2 = await FriendRepository.findFriendIds(2);
expect(friendIdsOfUser2).toHaveLength(1);
expect(friendIdsOfUser2).toContain(1);
});
test("findFriendIds returns empty array with no friends", async () => {
const friendIds = await FriendRepository.findFriendIds(1);
expect(friendIds).toHaveLength(0);
});
});
describe("deleteFriendship", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("removes friendship", async () => {
await createFriendship({ senderId: 1, receiverId: 2 });
const friendship = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 2,
});
await FriendRepository.deleteFriendship({
id: friendship!.id,
userId: 1,
});
const result = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 2,
});
expect(result).toBeUndefined();
});
test("does not delete friendship user is not part of", async () => {
await createFriendship({ senderId: 1, receiverId: 2 });
const friendship = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 2,
});
await FriendRepository.deleteFriendship({
id: friendship!.id,
userId: 3,
});
const result = await FriendRepository.findFriendship({
userOneId: 1,
userTwoId: 2,
});
expect(result).toBeDefined();
});
});
describe("findFriendRequestByIdAndReceiver", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("returns sender ID when request exists for receiver", async () => {
const requestId = await createFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestByIdAndReceiver({
id: requestId,
receiverId: 2,
});
expect(result).toBeDefined();
expect(result!.senderId).toBe(1);
});
test("returns undefined for wrong receiver", async () => {
const requestId = await createFriendRequest({
senderId: 1,
receiverId: 2,
});
const result = await FriendRepository.findFriendRequestByIdAndReceiver({
id: requestId,
receiverId: 3,
});
expect(result).toBeUndefined();
});
});
describe("findMutualFriends", () => {
beforeEach(async () => {
await dbInsertUsers(4);
});
afterEach(() => {
dbReset();
});
test("returns mutual friend when two users share a common friend", async () => {
await createFriendship({ senderId: 1, receiverId: 3 });
await createFriendship({ senderId: 2, receiverId: 3 });
const mutuals = await FriendRepository.findMutualFriends({
loggedInUserId: 1,
targetUserId: 2,
});
expect(mutuals).toHaveLength(1);
expect(mutuals[0].id).toBe(3);
});
test("returns empty array when no common friends", async () => {
await createFriendship({ senderId: 1, receiverId: 3 });
await createFriendship({ senderId: 2, receiverId: 4 });
const mutuals = await FriendRepository.findMutualFriends({
loggedInUserId: 1,
targetUserId: 2,
});
expect(mutuals).toHaveLength(0);
});
});
describe("findByUserIdWithActivity", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("returns friends with friendshipId and createdAt", async () => {
await createFriendship({ senderId: 1, receiverId: 2 });
const result = await FriendRepository.findByUserIdWithActivity(1);
const friendRow = result.find((r) => r.discordId === "1");
expect(friendRow).toBeDefined();
expect(friendRow!.friendshipId).toBeTypeOf("number");
expect(friendRow!.friendshipCreatedAt).toBeTypeOf("number");
});
test("returns empty array when user has no friends or team members", async () => {
const result = await FriendRepository.findByUserIdWithActivity(1);
expect(result).toHaveLength(0);
});
});

View File

@ -37,14 +37,6 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
const createdGroup = SendouQ.findOwnGroup(user.id);
if (createdGroup?.chatCode) {
setGroupChatMetadata({
chatCode: createdGroup.chatCode,
members: createdGroup.members,
});
}
return redirect(
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
);

View File

@ -46,6 +46,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
},
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
chatCode: ownGroup ? ownGroup.chatCode : null,
chatCode:
ownGroup && ownGroup.members.length > 1 ? ownGroup.chatCode : null,
};
};

View File

@ -131,6 +131,7 @@ export default function SettingsPage() {
</Divider>
<ThemeSelector />
<CustomColorSelector />
<FormMessage type="info">{t("common:settings.themeInfo")}</FormMessage>
</div>
</Main>
);

View File

@ -1,4 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
send: vi.fn(),
removeRoom: vi.fn(),
setMetadata: vi.fn(),
}));
import type { adminActionSchema } from "~/features/tournament/tournament-schemas.server";
import {
dbInsertTournament,

View File

@ -100,7 +100,7 @@ export default function UserEditPage() {
<FormMessage type="info">
<Trans i18nKey={"user:discordExplanation"} t={t}>
Username, profile picture, YouTube, Bluesky and Twitch accounts
come from your Discord account. See{" "}
come from your Discord account. See
<Link to={FAQ_PAGE}>FAQ</Link> for more information.
</Trans>
</FormMessage>

View File

@ -124,6 +124,14 @@
background: transparent;
margin: auto 0;
outline: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text);
-webkit-background-clip: text;
caret-color: var(--color-text);
}
}
.input-addon {
@ -149,6 +157,14 @@
font-size: var(--font-sm);
outline: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text);
-webkit-background-clip: text;
caret-color: var(--color-text);
}
&::placeholder {
color: var(--color-text-high);
}

View File

@ -22,5 +22,8 @@ export const logger = {
info: (...args: unknown[]) => console.log(...formatLog(...args)),
error: (...args: unknown[]) => console.error(...formatLog(...args)),
warn: (...args: unknown[]) => console.warn(...formatLog(...args)),
debug: (...args: unknown[]) => console.debug(...formatLog(...args)),
debug: (...args: unknown[]) => {
if (process.env.NODE_ENV === "production") return;
console.debug(...formatLog(...args));
},
};

View File

@ -20,7 +20,7 @@ test.describe("Associations", () => {
url: "/",
});
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-association").click();
await page.getByLabel("Name").fill("My Association");

View File

@ -39,9 +39,6 @@ async function banUser(
await waitForPOSTResponse(page, () =>
banForm.getByRole("button", { name: "Save" }).click(),
);
// Verify ban was successful
await expect(page.getByText("User banned")).toBeVisible();
}
async function unbanUser(page: Page) {

33
e2e/events.spec.ts Normal file
View File

@ -0,0 +1,33 @@
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
import { EVENTS_PAGE } from "~/utils/urls";
test.describe("Events", () => {
test("filters between tabs and navigates to an event", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: EVENTS_PAGE });
await expect(page.getByText("My Events")).toBeVisible();
const eventLinks = page.getByRole("link").filter({ hasText: /.+/ });
await expect(eventLinks.first()).toBeVisible();
await page.getByRole("link", { name: /Scrims/ }).click();
await expect(
page.getByRole("link").filter({ hasText: /.+/ }).first(),
).toBeVisible();
await page.getByRole("link", { name: /Saved/ }).click();
await expect(page.getByText("No events in this category")).toBeVisible();
await page.getByRole("link", { name: /Hosting/ }).click();
const firstEventLink = page
.getByRole("link")
.filter({ hasText: /.+/ })
.first();
await expect(firstEventLink).toBeVisible();
await firstEventLink.click();
await expect(page).not.toHaveURL(/\/events/);
});
});

43
e2e/friends.spec.ts Normal file
View File

@ -0,0 +1,43 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import {
expect,
impersonate,
navigate,
seed,
selectUser,
submit,
test,
waitForPOSTResponse,
} from "~/utils/playwright";
import { FRIENDS_PAGE } from "~/utils/urls";
test.describe("Friends", () => {
test("send friend request, accept it, then delete friend", async ({
page,
}) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: FRIENDS_PAGE });
await selectUser({ page, userName: "N-ZAP", labelName: "User" });
await submit(page);
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: FRIENDS_PAGE });
await expect(page.getByRole("button", { name: "Accept" })).toBeVisible();
await waitForPOSTResponse(page, () =>
page.getByRole("button", { name: "Accept" }).click(),
);
await page.getByRole("button", { name: "Sendou" }).click();
await page.getByText("Delete friend").click();
await waitForPOSTResponse(page, () =>
page.getByRole("button", { name: "Delete" }).click(),
);
await expect(page.getByText("No friends yet")).toBeVisible();
});
});

45
e2e/global-search.spec.ts Normal file
View File

@ -0,0 +1,45 @@
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
test.describe("Global search", () => {
test("searches for users and organizations", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
const searchDialog = page.getByRole("dialog", { name: "Search" });
await page.getByRole("button", { name: /Search/ }).click();
await searchDialog.waitFor({ state: "visible" });
await searchDialog.getByText("Users").click();
await page.getByPlaceholder("Search...").fill("sendou");
await page.getByRole("option", { name: /Sendou/ }).click();
await expect(page).toHaveURL(/\/u\/sendou/);
await page.getByRole("button", { name: /Search/ }).click();
await searchDialog.waitFor({ state: "visible" });
await searchDialog.getByText("Organizations").click();
await page.getByPlaceholder("Search...").fill("sendou");
await page.getByRole("option", { name: /sendou\.ink/ }).click();
await expect(page).toHaveURL(/\/org\/sendouink/);
});
test("searches for weapons", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
const searchDialog = page.getByRole("dialog", { name: "Search" });
await page.getByRole("button", { name: /Search/ }).click();
await searchDialog.waitFor({ state: "visible" });
await page.getByPlaceholder("Search...").fill("splattershot");
const weaponOption = page.getByRole("option", {
name: "Splattershot",
exact: true,
});
await weaponOption.waitFor({ state: "visible" });
await weaponOption.click({ force: true });
await page.getByRole("option", { name: "Builds", exact: true }).click();
await expect(page).toHaveURL(/\/builds\/splattershot/);
});
});

View File

@ -17,7 +17,7 @@ test.describe("LFG", () => {
url: LFG_PAGE,
});
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("looking for a cool team");
@ -38,14 +38,13 @@ test.describe("LFG", () => {
});
// create post with Japanese and Korean
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("looking for Japanese/Korean team");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("ja");
await languageSelect.selectOption("ko");
await page.getByLabel("日本語").check();
await page.getByLabel("한국어").check();
await submit(page);
@ -62,18 +61,17 @@ test.describe("LFG", () => {
});
// create post with Dansk
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("test post for language editing");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("da");
await page.getByLabel("Dansk").check();
await submit(page);
// wait for redirect to LFG page & verify the language is displayed
await expect(page.getByText("DA / EN", { exact: true })).toBeVisible();
await expect(page.getByText("EN / DA", { exact: true })).toBeVisible();
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(
page.getByText("test post for language editing"),
@ -81,8 +79,8 @@ test.describe("LFG", () => {
// remove Dansk and add Spanish
await page.getByRole("link", { name: "Edit" }).first().click();
await page.getByText("Dansk").locator("..").getByRole("button").click();
await languageSelect.selectOption("es");
await page.getByLabel("Dansk").uncheck();
await page.getByLabel("Español").check();
await submit(page);
@ -105,13 +103,12 @@ test.describe("LFG", () => {
});
// create post with Japanese
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("Japanese speaking team");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("ja");
await page.getByLabel("日本語").check();
await submit(page);

150
e2e/navigation.spec.ts Normal file
View File

@ -0,0 +1,150 @@
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
test.describe("Navigation", () => {
test("desktop navigation", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
// SideNav visible with section headings
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible();
// View all links present
const viewAllLinks = page.getByRole("link", { name: /View all/ });
await expect(viewAllLinks.first()).toBeVisible();
// SideNav collapse/uncollapse
const collapseButton = page.getByTestId("sidenav-collapse-button");
await collapseButton.click();
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
await collapseButton.click();
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
// TopNavMenus — Play
await page.getByRole("button", { name: "Play" }).click();
await expect(page.getByRole("link", { name: "SendouQ" })).toBeVisible();
await expect(page.getByRole("link", { name: "Scrims" })).toBeVisible();
await page.getByRole("link", { name: "SendouQ" }).click();
await expect(page.getByRole("link", { name: "SendouQ" })).not.toBeVisible();
await navigate({ page, url: "/" });
// TopNavMenus — Tools
await page.getByRole("button", { name: "Tools" }).click();
await expect(page.getByRole("link", { name: "Analyzer" })).toBeVisible();
await page.keyboard.press("Escape");
// TopNavMenus — Community
await page.getByRole("button", { name: "Community" }).click();
await expect(page.getByRole("link", { name: "Builds" })).toBeVisible();
await page.keyboard.press("Escape");
// SideNav footer — user info
await expect(page.getByTestId("notifications-button")).toBeVisible();
});
test("mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
// Tab bar visible
const menuTab = page.getByRole("button", { name: "Menu" });
const friendsTab = page.getByRole("button", { name: "Friends" });
const calendarTab = page.getByRole("button", { name: "Events" });
const chatTab = page.getByRole("button", { name: "Chat" });
const youTab = page.getByRole("button", { name: "You" });
await expect(menuTab).toBeVisible();
await expect(friendsTab).toBeVisible();
await expect(calendarTab).toBeVisible();
await expect(chatTab).toBeVisible();
await expect(youTab).toBeVisible();
// Menu panel — open and verify contents
await menuTab.click();
await expect(page.getByRole("link", { name: "SendouQ" })).toBeVisible();
await expect(page.getByRole("link", { name: "Analyzer" })).toBeVisible();
await expect(page.getByRole("link", { name: "Builds" })).toBeVisible();
await expect(
page.locator("h3").filter({ hasText: "Streams" }),
).toBeVisible();
// Switch from menu to friends panel via ghost tab
// Ghost tabs are invisible overlays; use dispatchEvent to trigger them
// nth(1) = "friends" ghost tab (0=menu, 1=friends, 2=tourneys, 3=chat, 4=you)
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(1)
.dispatchEvent("click");
await expect(page.getByRole("link", { name: "SendouQ" })).not.toBeVisible();
const friendsViewAll = page.getByRole("link", { name: /View all/ });
await expect(friendsViewAll).toBeVisible();
// Switch to You panel via ghost tab (nth(4) = "you")
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(4)
.dispatchEvent("click");
// You panel shows user info (username link)
await expect(page.locator("[class*='youPanelUsername']")).toBeVisible();
// Switch to calendar panel via ghost tab (nth(2) = "tourneys")
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(2)
.dispatchEvent("click");
const tourneysViewAll = page.getByRole("link", { name: /View all/ });
await expect(tourneysViewAll).toBeVisible();
// Close panel via X button
await page.locator("button:has(svg.lucide-x)").first().click();
await expect(tourneysViewAll).not.toBeVisible();
});
test("tablet navigation", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
// SideNav not visible as permanent sidebar
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "Friends" }),
).not.toBeVisible();
// Hamburger opens SideNav modal
const modalTrigger = page.getByTestId("sidenav-modal-trigger");
await modalTrigger.click();
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible();
await expect(
page.getByRole("link", { name: /View all/ }).first(),
).toBeVisible();
// Close modal by pressing Escape
await page.keyboard.press("Escape");
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
// TopNavMenus still work
await page.getByRole("button", { name: "Play" }).click();
await expect(page.getByRole("link", { name: "SendouQ" })).toBeVisible();
await page.keyboard.press("Escape");
// MobileNav hidden
await expect(page.getByRole("button", { name: "Menu" })).not.toBeVisible();
});
});

View File

@ -32,7 +32,7 @@ test.describe("Tournament Organization", () => {
await impersonate(page);
await navigate({ page, url: "/" });
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-organization").click();
const form = createFormHelpers(page, newOrganizationSchema);
@ -144,10 +144,12 @@ test.describe("Tournament Organization", () => {
// Fill in team details
await page.getByLabel("Team name").fill("Banned Team");
await page.getByRole("button", { name: "Save" }).click();
await waitForPOSTResponse(page, () =>
page.getByTestId("save-team-button").click(),
);
// Verify error toast appears indicating user is banned
await expect(page.getByText(/you are banned/i)).toBeVisible();
// Verify the team was not created (Fill roster only appears after successful registration)
await expect(page.getByText("Fill roster")).not.toBeVisible();
// 3. As admin, remove the ban
await impersonate(page, ADMIN_ID);
@ -165,12 +167,15 @@ test.describe("Tournament Organization", () => {
await page.getByRole("tab", { name: "Register" }).click();
// Try to create a team again
await expect(page.getByText("Teams (0)")).toBeVisible();
await expect(page.getByText(/Teams \(\d+\)/)).toBeVisible();
const teamCountBefore = await page.getByText(/Teams \(\d+\)/).textContent();
await page.getByLabel("Team name").fill("Unbanned Team");
await page.getByRole("button", { name: "Save" }).click();
await page.getByTestId("save-team-button").click();
await expect(page.getByText("Teams (1)")).toBeVisible();
const countBefore = Number(teamCountBefore?.match(/\d+/)?.[0] ?? 0);
await expect(page.getByText(`Teams (${countBefore + 1})`)).toBeVisible();
// 5. As admin, ban user again but with permanent ban this time
await impersonate(page, ADMIN_ID);

View File

@ -22,7 +22,7 @@ test.describe("Scrims", () => {
url: "/",
});
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-scrimPost").click();
const form = createFormHelpers(page, scrimsNewFormSchema);

View File

@ -11,7 +11,7 @@ import {
waitForPOSTResponse,
} from "~/utils/playwright";
import { createFormHelpers } from "~/utils/playwright-form";
import { SETTINGS_PAGE } from "~/utils/urls";
import { CALENDAR_PAGE, SETTINGS_PAGE } from "~/utils/urls";
test.describe("Settings", () => {
test("updates 'disableBuildAbilitySorting'", async ({ page }) => {
@ -56,12 +56,11 @@ test.describe("Settings", () => {
await navigate({
page,
url: "/",
url: CALENDAR_PAGE,
});
const tournamentCard = page.getByTestId("tournament-card").first();
const timeElement = tournamentCard.locator("time");
const initialTime = await timeElement.textContent();
const clockHeader = page.locator("[class*='clockHeader']").first();
const initialTime = await clockHeader.locator("span").first().textContent();
expect(initialTime).toMatch(/AM|PM/);
@ -75,10 +74,10 @@ test.describe("Settings", () => {
await navigate({
page,
url: "/",
url: CALENDAR_PAGE,
});
const newTime = await tournamentCard.locator("time").textContent();
const newTime = await clockHeader.locator("span").first().textContent();
expect(newTime).not.toMatch(/AM|PM/);
expect(newTime).not.toBe(initialTime);

View File

@ -20,7 +20,7 @@ test.describe("New team creation", () => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-team").click();
await expect(page).toHaveURL(/t\/new/);

View File

@ -136,6 +136,5 @@ test.describe("Tournament staff", () => {
});
await expect(roomPassSelector).toBeVisible();
await expect(page.getByTestId("chat-tab")).toBeVisible();
});
});

View File

@ -10,6 +10,7 @@ import {
seed,
submit,
test,
waitForPOSTResponse,
} from "~/utils/playwright";
import { createFormHelpers } from "~/utils/playwright-form";
import { userEditProfilePage, userPage } from "~/utils/urls";
@ -133,14 +134,18 @@ test.describe("User page", () => {
await baseHueSlider.fill("120");
// save
await page.getByRole("button", { name: "Save" }).first().click();
await waitForPOSTResponse(page, () =>
page.getByRole("button", { name: "Save" }).first().click(),
);
await page.reload();
// verify custom theme was applied
await expect(hasCustomTheme()).resolves.toBe(true);
// reset
await page.getByRole("button", { name: "Reset" }).first().click();
await waitForPOSTResponse(page, () =>
page.getByRole("button", { name: "Reset" }).first().click(),
);
await page.reload();
// verify custom theme was removed

View File

@ -331,6 +331,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -331,6 +331,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -331,6 +331,7 @@
"settings.notifications.permissionDenied": "Push notifications were denied. Check your browser settings to re-enable",
"settings.clockFormat": "Clock format",
"settings.theme": "Theme",
"settings.themeInfo": "This also sets the colors of your user page to others",
"settings.locales": "Language",
"settings.preferences": "Preferences",
"settings.customTheme.colors": "Colors",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "El permiso de notificaciones fue denegado. Actívalo en la configuración de tu navegador.",
"settings.clockFormat": "Formato de hora",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "Les notifications push ont été refusées. Vérifiez les paramètres de votre navigateur pour les réactiver",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -332,6 +332,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "Le notifiche push sono state negate. Controlla le impostazioni del browser per riattivarle",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -327,6 +327,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -327,6 +327,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -331,6 +331,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -334,6 +334,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -333,6 +333,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -334,6 +334,7 @@
"settings.notifications.permissionDenied": "Push-уведомления были запрещены. Проверьте настройки вашего бразуера, чтобы включить их обратно",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",

View File

@ -327,6 +327,7 @@
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"settings.theme": "",
"settings.themeInfo": "",
"settings.locales": "",
"settings.preferences": "",
"settings.customTheme.colors": "",