diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index b56f594e8..9de021a51 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -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); diff --git a/app/components/elements/Toast.browser.test.tsx b/app/components/elements/Toast.browser.test.tsx new file mode 100644 index 000000000..91ed24e7b --- /dev/null +++ b/app/components/elements/Toast.browser.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { SendouToastRegion, toastQueue } from "./Toast"; + +function Wrapper() { + return ; +} + +describe("Toast", () => { + test("renders success toast", async () => { + const screen = await render(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/app/components/elements/Toast.module.css b/app/components/elements/Toast.module.css index e2233971c..03394d817 100644 --- a/app/components/elements/Toast.module.css +++ b/app/components/elements/Toast.module.css @@ -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; } diff --git a/app/components/elements/Toast.tsx b/app/components/elements/Toast.tsx index e7da1f767..3cddf7886 100644 --- a/app/components/elements/Toast.tsx +++ b/app/components/elements/Toast.tsx @@ -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 ( - + {({ toast }) => ( 0} + testId="sidenav-modal-trigger" /> @@ -364,6 +365,7 @@ export function Layout({ onToggle={() => setSideNavCollapsed(!sideNavCollapsed)} className={styles.sideNavCollapseButton} showNotificationDot={sideNavCollapsed && unseenIds.length > 0} + testId="sidenav-collapse-button" /> void; className?: string; showNotificationDot?: boolean; + testId?: string; }) { return ( -
+
} + testId="chat-submit-button" />
diff --git a/app/features/friends/FriendRepository.server.test.ts b/app/features/friends/FriendRepository.server.test.ts new file mode 100644 index 000000000..592e0e499 --- /dev/null +++ b/app/features/friends/FriendRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index c8932255a..af9a6b52b 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -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, ); diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index 4094a2e8a..a943fb45c 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -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, }; }; diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index d5558a03a..e47678c5a 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -131,6 +131,7 @@ export default function SettingsPage() { + {t("common:settings.themeInfo")}
); diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts index a71f1ef76..84c8c4721 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.test.ts @@ -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, diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 488d8d46e..9f16910b5 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -100,7 +100,7 @@ export default function UserEditPage() { Username, profile picture, YouTube, Bluesky and Twitch accounts - come from your Discord account. See{" "} + come from your Discord account. See FAQ for more information. diff --git a/app/styles/common.css b/app/styles/common.css index bfdbf952d..95d1149ce 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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); } diff --git a/app/utils/logger.ts b/app/utils/logger.ts index b3ac0d27f..c225529aa 100644 --- a/app/utils/logger.ts +++ b/app/utils/logger.ts @@ -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)); + }, }; diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index 6731466d9..ef53c7274 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -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"); diff --git a/e2e/ban.spec.ts b/e2e/ban.spec.ts index 6421abcf9..0e1ec89c8 100644 --- a/e2e/ban.spec.ts +++ b/e2e/ban.spec.ts @@ -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) { diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts new file mode 100644 index 000000000..01df43c2c --- /dev/null +++ b/e2e/events.spec.ts @@ -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/); + }); +}); diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts new file mode 100644 index 000000000..c33aba243 --- /dev/null +++ b/e2e/friends.spec.ts @@ -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(); + }); +}); diff --git a/e2e/global-search.spec.ts b/e2e/global-search.spec.ts new file mode 100644 index 000000000..84cc2be9d --- /dev/null +++ b/e2e/global-search.spec.ts @@ -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/); + }); +}); diff --git a/e2e/lfg.spec.ts b/e2e/lfg.spec.ts index 8d71a40bd..17da92adc 100644 --- a/e2e/lfg.spec.ts +++ b/e2e/lfg.spec.ts @@ -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); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 000000000..f2643e76b --- /dev/null +++ b/e2e/navigation.spec.ts @@ -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(); + }); +}); diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 36b68e775..28a472e52 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -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); diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index f15ff69b3..561fe7e54 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -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); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 8c1d62517..91ce7387a 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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); diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 553319753..acf4d24c3 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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/); diff --git a/e2e/tournament-staff.spec.ts b/e2e/tournament-staff.spec.ts index 7e9ef4a41..59b58ae8d 100644 --- a/e2e/tournament-staff.spec.ts +++ b/e2e/tournament-staff.spec.ts @@ -136,6 +136,5 @@ test.describe("Tournament staff", () => { }); await expect(roomPassSelector).toBeVisible(); - await expect(page.getByTestId("chat-tab")).toBeVisible(); }); }); diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index 616fff71d..f6c9bec9b 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -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 diff --git a/locales/da/common.json b/locales/da/common.json index b19cd1470..b142bfad1 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -331,6 +331,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/de/common.json b/locales/de/common.json index 8cce8affa..0689b1f42 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -331,6 +331,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/en/common.json b/locales/en/common.json index e404f0a50..535218ba9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 6a6297062..99c8a72e2 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index d46213c3a..30a92a4c6 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -333,6 +333,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index bbac117ee..b923d2625 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -333,6 +333,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 6cb0a5add..49b133b4e 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "", diff --git a/locales/he/common.json b/locales/he/common.json index 64840999e..969c188b1 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -332,6 +332,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/it/common.json b/locales/it/common.json index 521e6c1ea..725dfe940 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0380b5579..7af1662e6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -327,6 +327,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1e00b3c75..9e6038d8b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -327,6 +327,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 50b673c5a..a916b6277 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -331,6 +331,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index e2f2d45e7..b71b244b6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -334,6 +334,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index d1449deb9..5e546c34d 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -333,6 +333,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 59b38214d..f3511cc97 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -334,6 +334,7 @@ "settings.notifications.permissionDenied": "Push-уведомления были запрещены. Проверьте настройки вашего бразуера, чтобы включить их обратно", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2f5dfd30b..2d3f87ca7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -327,6 +327,7 @@ "settings.notifications.permissionDenied": "", "settings.clockFormat": "", "settings.theme": "", + "settings.themeInfo": "", "settings.locales": "", "settings.preferences": "", "settings.customTheme.colors": "",