From ac40e9b0fc0b79a3d136632875ee856c97312b61 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:55:58 +0300 Subject: [PATCH] Add sidebar e2e tests, fix external stream links and offline casts showing as live --- app/components/Catcher.tsx | 33 +- app/components/Main.tsx | 3 + app/db/seed/factories/TournamentFactory.ts | 32 + .../api-private/core/refresh-caches.server.ts | 6 +- app/features/friends/friends-utils.server.ts | 18 +- .../core/Tournament.server.ts | 11 + app/root.tsx | 15 +- e2e/helpers/factories.ts | 6 + e2e/helpers/playwright.ts | 8 + e2e/helpers/sidebar.ts | 73 ++ e2e/helpers/tournament.ts | 92 ++- e2e/navigation.spec.ts | 70 +- e2e/pages/friends/friends-page.ts | 3 +- e2e/pages/layout/mobile-nav.ts | 25 +- e2e/pages/layout/side-nav.ts | 114 +++- e2e/sidebar-events.spec.ts | 176 +++++ e2e/sidebar-friends.spec.ts | 625 ++++++++++++++++++ e2e/sidebar-streams.spec.ts | 212 ++++++ 18 files changed, 1445 insertions(+), 77 deletions(-) create mode 100644 e2e/helpers/sidebar.ts create mode 100644 e2e/sidebar-events.spec.ts create mode 100644 e2e/sidebar-friends.spec.ts create mode 100644 e2e/sidebar-streams.spec.ts diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index a44da4f74..03798e37d 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -45,7 +45,7 @@ export function Catcher() { if (isNetworkError) { return ( -
+

Connection error

@@ -55,7 +55,7 @@ export function Catcher() {

-
+ ); } @@ -68,7 +68,7 @@ export function Catcher() { })(); return ( -
+

Error happened

@@ -84,7 +84,7 @@ export function Catcher() { ) : null} -

+ ); } @@ -92,7 +92,7 @@ export function Catcher() { case 401: if (!user) { return ( -
+

Authentication required

This page requires you to be logged in.

@@ -100,36 +100,36 @@ export function Catcher() { Log in via Discord
-
+ ); } return ( -
+

Error 401 Unauthorized

-
+ ); case 403: return ( -
+

Error 403 Forbidden

Your account doesn't have the required permissions to perform this action.

-
+ ); case 404: return ( -
+

Error {error.status} - Page not found

-
+ ); default: return ( -
+

Error {error.status}

@@ -142,11 +142,16 @@ export function Catcher() { ? `\n${typeof error.data === "string" ? error.data : JSON.stringify(error.data, null, 2)}` : null} -
+ ); } } +/** Every branch of the error page, marked so tests can assert one is not shown. */ +function ErrorMain({ children }: { children: React.ReactNode }) { + return
{children}
; +} + function GetHelp() { return (

diff --git a/app/components/Main.tsx b/app/components/Main.tsx index 91eeb3eba..a6248c23d 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -10,6 +10,7 @@ export const Main = ({ bigger, breakoutContainer, style, + testId, }: { children: React.ReactNode; className?: string; @@ -18,6 +19,7 @@ export const Main = ({ bigger?: boolean; breakoutContainer?: boolean; style?: React.CSSProperties; + testId?: string; }) => { return (

{children} diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index 3b5b0ed67..72d0a387e 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -226,6 +226,38 @@ export async function startBracket( return startedBracket.data.match.map((match) => ({ id: match.id })); } +/** + * Marks a match as casted by a Twitch account, adding the account to the + * tournament's cast accounts first, the way the organizer's stream page does. + */ +export async function castMatch({ + tournamentId, + matchId, + twitchAccount, +}: { + tournamentId: number; + matchId: number; + twitchAccount: string; +}) { + const tournament = await TournamentRepository.findById(tournamentId); + invariant(tournament, `Tournament ${tournamentId} not found`); + + await TournamentRepository.updateCastTwitchAccounts({ + tournamentId, + castTwitchAccounts: [ + ...(tournament.castTwitchAccounts ?? []), + twitchAccount, + ], + }); + await TournamentRepository.setMatchAsCasted({ + tournamentId, + matchId, + twitchAccount, + }); + + clearTournamentDataCache(tournamentId); +} + interface PlayedMatch { id: number; /** Index of the bracket the match belongs to in the progression. */ diff --git a/app/features/api-private/core/refresh-caches.server.ts b/app/features/api-private/core/refresh-caches.server.ts index ba7d4baf1..6a49a3d5d 100644 --- a/app/features/api-private/core/refresh-caches.server.ts +++ b/app/features/api-private/core/refresh-caches.server.ts @@ -2,7 +2,10 @@ import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.se import { refreshBannedCache } from "~/features/ban/core/banned.server"; import { clearParticipationInfoMap } from "~/features/front-page/core/ShowcaseTournaments.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; -import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import { + clearAllTournamentDataCache, + refreshRunningTournaments, +} from "~/features/tournament-bracket/core/Tournament.server"; import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server"; import { cache } from "~/utils/cache.server"; @@ -19,4 +22,5 @@ export async function refreshCaches() { await refreshSendouQInstance(); await refreshTentativeTiersCache(); await refreshApiTokensCache(); + await refreshRunningTournaments(); } diff --git a/app/features/friends/friends-utils.server.ts b/app/features/friends/friends-utils.server.ts index 0e4c4febd..ac5bc137b 100644 --- a/app/features/friends/friends-utils.server.ts +++ b/app/features/friends/friends-utils.server.ts @@ -188,9 +188,7 @@ function tournamentStreamUrl({ ); if (teammateAccount) return twitchUrl(teammateAccount); - const castAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find( - (castedMatch) => castedMatch.matchId === matchId, - )?.twitchAccount; + const castAccount = liveCastAccount(tournament, matchId); if (castAccount) return twitchUrl(castAccount); const opponentAccount = streamingTwitchAccount( @@ -201,6 +199,20 @@ function tournamentStreamUrl({ return opponentAccount ? twitchUrl(opponentAccount) : null; } +/** Account casting the match, only when it is actually streaming right now. */ +function liveCastAccount(tournament: Tournament, matchId: number) { + const castAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find( + (castedMatch) => castedMatch.matchId === matchId, + )?.twitchAccount; + if (!castAccount) return null; + + const isLive = tournament.ctx.castStreams.some( + (stream) => stream.twitch?.toLowerCase() === castAccount.toLowerCase(), + ); + + return isLive ? castAccount : null; +} + function streamingTwitchAccount( players: Array<{ userId: number; streamTwitch: string | null }>, streamingParticipantIds: ReadonlySet, diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 68f331e6c..fb28fe525 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -188,6 +188,17 @@ function syncTournamentToRegistry(tournament: Tournament) { } } +/** + * Rebuilds the running tournaments registry from the database, forgetting the + * tournaments it held. E2E workers call this (via `/refresh-caches`) after + * writing tournaments straight into the database file. + */ +export async function refreshRunningTournaments() { + RunningTournaments.clear(); + + await primeRunningTournamentsCache(); +} + async function primeRunningTournamentsCache() { const tournamentIds = await TournamentRepository.findRunningTournamentIds(); diff --git a/app/root.tsx b/app/root.tsx index 2aa9daebc..11fdf9cc1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -235,7 +235,7 @@ function Document({ {IS_E2E_TEST_RUN && } - + @@ -253,6 +253,19 @@ function Document({ ); } +const ABSOLUTE_URL_REGEX = /^[a-z][a-z\d+\-.]*:/i; + +/** + * Href every React Aria link (menu items, buttons, tabs) is rendered with. + * `useHref` resolves its argument against the current route, which would turn an + * absolute URL such as a Twitch link into a path of our own. + */ +function useExternalAwareHref(href: string) { + const resolved = useHref(href); + + return ABSOLUTE_URL_REGEX.test(href) ? href : resolved; +} + function useTriggerToasts() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index d2fa61d5e..cbf61cab8 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -40,12 +40,18 @@ export async function loadFactories(parallelIndex: number) { CalendarEventFactory: await import( "~/db/seed/factories/CalendarEventFactory" ), + FriendRequestFactory: await import( + "~/db/seed/factories/FriendRequestFactory" + ), FriendshipFactory: await import("~/db/seed/factories/FriendshipFactory"), LFGPostFactory: await import("~/db/seed/factories/LFGPostFactory"), LiveStreamFactory: await import("~/db/seed/factories/LiveStreamFactory"), NotificationFactory: await import( "~/db/seed/factories/NotificationFactory" ), + SavedCalendarEventFactory: await import( + "~/db/seed/factories/SavedCalendarEventFactory" + ), ScrimPostFactory: await import("~/db/seed/factories/ScrimPostFactory"), SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"), SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"), diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index c8229b085..a270efbe7 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -21,6 +21,9 @@ try { } export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500; +export const MOBILE_VIEWPORT = { width: 375, height: 667 }; +export const TABLET_VIEWPORT = { width: 768, height: 1024 }; + type WorkerFixtures = { workerPort: number; workerBaseURL: string; @@ -266,6 +269,11 @@ async function expectRouterIdle(page: Page) { ); } +/** Asserts the page rendered rather than the error boundary catching something. */ +export async function expectNoErrorPage(page: Page) { + await expect(page.getByTestId("error-page")).toHaveCount(0); +} + export function isNotVisible(locator: Locator) { return expect(locator).toHaveCount(0); } diff --git a/e2e/helpers/sidebar.ts b/e2e/helpers/sidebar.ts new file mode 100644 index 000000000..309e6ef6d --- /dev/null +++ b/e2e/helpers/sidebar.ts @@ -0,0 +1,73 @@ +import type { Locator } from "@playwright/test"; +import { addDays, addHours } from "date-fns"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import type { Factories } from "./factories"; +import { expect } from "./playwright"; + +export const NO_EVENTS_TEXT = "No upcoming events"; +export const NO_STREAMS_TEXT = "No streams currently"; +export const NO_FRIENDS_TEXT = "Add friends to see their activity here"; +export const LOGGED_OUT_FRIENDS_TEXT = + "Log in to follow your friends' activity"; + +export function inHours(hours: number) { + return dateToDatabaseTimestamp(addHours(new Date(), hours)); +} + +export function inDays(days: number) { + return dateToDatabaseTimestamp(addDays(new Date(), days)); +} + +/** The Twitch account `LiveStreamFactory` gives a user's stream. */ +export function streamAccountOf(userId: number) { + return `stream_${userId}`; +} + +/** Users the sidebar rows can be found by, one per name. */ +export function createNamedUsers( + factories: Factories, + names: string[], + overrides?: { twitch?: string }, +) { + return factories.UserFactory.createMany(names.length, (index) => ({ + ...overrides, + discordName: names[index], + })); +} + +export async function createUserIds(factories: Factories, count: number) { + const users = await factories.UserFactory.createMany(count); + + return users.map((user) => user.id); +} + +export function befriend( + factories: Factories, + friendIds: number[], + userId = NZAP_TEST_ID, +) { + return Promise.all( + friendIds.map((friendId) => + factories.FriendshipFactory.create({ + userOneId: userId, + userTwoId: friendId, + }), + ), + ); +} + +/** Asserts the rows are rendered in the order given, top to bottom. */ +export async function expectTopToBottom(rows: Locator[]) { + const tops: number[] = []; + + for (const row of rows) { + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + invariant(box, "Visible row has no bounding box"); + tops.push(box.y); + } + + expect(tops).toEqual([...tops].sort((a, b) => a - b)); +} diff --git a/e2e/helpers/tournament.ts b/e2e/helpers/tournament.ts index c9b5251b6..93074e6d4 100644 --- a/e2e/helpers/tournament.ts +++ b/e2e/helpers/tournament.ts @@ -1,7 +1,10 @@ -import { subMinutes } from "date-fns"; +import { addHours, subMinutes } from "date-fns"; import type { TournamentSettings } from "~/db/tables-json"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "~/utils/invariant"; import type { Factories } from "./factories"; export const ROSTER_SIZE = 4; @@ -166,3 +169,90 @@ export async function createTeams( export function startedTournamentTimes() { return [dateToDatabaseTimestamp(subMinutes(new Date(), 30))]; } + +/** + * A tournament running an unfinished match: two checked-in teams and the bracket + * started, which is all it takes for both teams to be "in a match". The friend and + * their teammate play the opponent, and every user given is on the roster it is + * named for. + */ +export async function createInProgressMatch( + factories: Factories, + { + name, + friendId, + teammateId, + opponentId, + tier, + }: { + name: string; + friendId: number; + teammateId?: number; + opponentId?: number; + tier?: TournamentTierNumber; + }, +) { + const tournament = await factories.TournamentFactory.create( + { + authorId: ADMIN_ID, + name, + startTimes: startedTournamentTimes(), + }, + { tier }, + ); + + const teams = await createTeams(factories, tournament.id, [ + { members: [friendId, ...(teammateId ? [teammateId] : [])] }, + { members: opponentId ? [opponentId] : [] }, + ]); + + const [match] = await factories.TournamentFactory.startBracket(tournament.id); + invariant(match, "Starting the bracket created no match"); + + return { tournament, teams, matchId: match.id }; +} + +/** An upcoming tournament where the given user's team is looking for subs. */ +export async function createSubSeekingTournament( + factories: Factories, + { name, subId }: { name: string; subId: number }, +) { + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); + + await factories.TournamentTeamFactory.create( + { tournamentId: tournament.id, memberUserIds: [subId] }, + { isLooking: true }, + ); + + return { tournament }; +} + +/** + * A tournament where the given user is waiting for their next match: three teams + * in a single elimination bracket leave the top seed with a bye into the final, + * whose other side is still being played for. + */ +export async function createTournamentWithByeTeam( + factories: Factories, + { name, waitingUserId }: { name: string; waitingUserId: number }, +) { + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name, + startTimes: startedTournamentTimes(), + }); + + await createTeams(factories, tournament.id, [ + { members: [waitingUserId] }, + {}, + {}, + ]); + + await factories.TournamentFactory.startBracket(tournament.id); + + return { tournament }; +} diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index b93461f73..0348279ef 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -1,7 +1,14 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; -import { expect, impersonate, navigate, test } from "./helpers/playwright"; +import { SENDOUQ_PAGE } from "~/utils/urls"; +import { + expect, + impersonate, + MOBILE_VIEWPORT, + navigate, + TABLET_VIEWPORT, + test, +} from "./helpers/playwright"; import { MobileNav } from "./pages/layout/mobile-nav"; -import { SideNav } from "./pages/layout/side-nav"; import { TopNavMenus } from "./pages/layout/top-nav-menus"; test.describe("Navigation", () => { @@ -9,41 +16,26 @@ test.describe("Navigation", () => { await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - const sideNav = new SideNav(page); - await expect(sideNav.sectionHeading("Events")).toBeVisible(); - await expect(sideNav.sectionHeading("Friends")).toBeVisible(); - await expect(sideNav.sectionHeading("Streams")).toBeVisible(); - await expect(sideNav.locators.viewAllLinks.first()).toBeVisible(); - - await sideNav.toggleCollapse(); - await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); - - await sideNav.toggleCollapse(); - await expect(sideNav.sectionHeading("Events")).toBeVisible(); - const topNav = new TopNavMenus(page); await topNav.open("Play"); await expect(topNav.link("SendouQ")).toBeVisible(); await expect(topNav.link("Scrims")).toBeVisible(); await topNav.link("SendouQ").click(); + await expect(page).toHaveURL(SENDOUQ_PAGE); await expect(topNav.link("SendouQ")).not.toBeVisible(); - await navigate({ page, url: "/" }); - await topNav.open("Tools"); - await expect(topNav.link("Analyzer")).toBeVisible(); - await topNav.close(); + await topNav.link("Analyzer").click(); + await expect(page).toHaveURL(/\/analyzer/); await topNav.open("Community"); - await expect(topNav.link("Builds")).toBeVisible(); - await topNav.close(); - - await expect(sideNav.locators.notificationsButton).toBeVisible(); + await topNav.link("Builds").click(); + await expect(page).toHaveURL(/\/builds/); }); test("mobile navigation", async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); + await page.setViewportSize(MOBILE_VIEWPORT); await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); @@ -55,44 +47,26 @@ test.describe("Navigation", () => { await expect(mobileNav.tab("you")).toBeVisible(); await mobileNav.openPanel("menu"); - await expect(mobileNav.menuLink("SendouQ")).toBeVisible(); await expect(mobileNav.menuLink("Analyzer")).toBeVisible(); await expect(mobileNav.menuLink("Builds")).toBeVisible(); - await expect(mobileNav.locators.streamsHeading).toBeVisible(); - - await mobileNav.switchPanel("friends"); - await expect(mobileNav.menuLink("SendouQ")).not.toBeVisible(); - await expect(mobileNav.locators.viewAllLink).toBeVisible(); await mobileNav.switchPanel("you"); await expect(mobileNav.locators.youPanelUsername).toBeVisible(); - await mobileNav.switchPanel("tourneys"); - await expect(mobileNav.locators.viewAllLink).toBeVisible(); - await mobileNav.closePanel(); - await expect(mobileNav.locators.viewAllLink).not.toBeVisible(); + await expect(mobileNav.locators.youPanelUsername).toHaveCount(0); + + await mobileNav.openPanel("menu"); + await mobileNav.menuLink("SendouQ").click(); + await expect(page).toHaveURL(SENDOUQ_PAGE); + await expect(mobileNav.menuLink("SendouQ")).not.toBeVisible(); }); test("tablet navigation", async ({ page }) => { - await page.setViewportSize({ width: 768, height: 1024 }); + await page.setViewportSize(TABLET_VIEWPORT); await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - const sideNav = new SideNav(page); - await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); - await expect(sideNav.sectionHeading("Friends")).not.toBeVisible(); - - await sideNav.openModal(); - - await expect(sideNav.sectionHeading("Events")).toBeVisible(); - await expect(sideNav.sectionHeading("Friends")).toBeVisible(); - await expect(sideNav.sectionHeading("Streams")).toBeVisible(); - await expect(sideNav.locators.viewAllLinks.first()).toBeVisible(); - - await sideNav.closeModal(); - await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); - const topNav = new TopNavMenus(page); await topNav.open("Play"); await expect(topNav.link("SendouQ")).toBeVisible(); diff --git a/e2e/pages/friends/friends-page.ts b/e2e/pages/friends/friends-page.ts index 4d1cde0da..50d2a3269 100644 --- a/e2e/pages/friends/friends-page.ts +++ b/e2e/pages/friends/friends-page.ts @@ -38,9 +38,10 @@ export class FriendsPage { await submit(this.page); } + /** Accepts the first pending request, the newest one. */ async acceptRequest() { await waitForPOSTResponse(this.page, () => - this.locators.acceptButton.click(), + this.locators.acceptButton.first().click(), ); } diff --git a/e2e/pages/layout/mobile-nav.ts b/e2e/pages/layout/mobile-nav.ts index 8de416a8e..66d44160c 100644 --- a/e2e/pages/layout/mobile-nav.ts +++ b/e2e/pages/layout/mobile-nav.ts @@ -1,4 +1,5 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; +import { FriendRow } from "./side-nav"; type Panel = "menu" | "friends" | "tourneys" | "chat" | "you"; @@ -12,18 +13,26 @@ const TAB_NAMES: Record = { const PANELS: Panel[] = ["menu", "friends", "tourneys", "chat", "you"]; -/** The bottom tab bar and its panels, rendered in place of the side nav on mobile. */ +/** + * The bottom tab bar and its panels, rendered in place of the side nav on mobile. + * + * The panels show the same rows as the side nav does, and its accessors find them + * the same way — scoped to the panel that is open. + */ export class MobileNav { private readonly page: Page; + private readonly openPanelDialog: Locator; readonly locators; constructor(page: Page) { this.page = page; + this.openPanelDialog = page.locator("[class*='panelDialog']:visible"); this.locators = { menuPanel: page.getByLabel("Menu", { exact: true }), streamsHeading: page.locator("h3").filter({ hasText: "Streams" }), viewAllLink: page.getByRole("link", { name: "View all", exact: true }), youPanelUsername: page.locator("[class*='youPanelUsername']"), + friendItems: this.openPanelDialog.locator("button[class*='listButton']"), }; } @@ -53,4 +62,16 @@ export class MobileNav { menuLink(name: string) { return this.locators.menuPanel.getByRole("link", { name }); } + + friend(name: string) { + return new FriendRow(this.page, this.openPanelDialog, name); + } + + eventItem(name: string) { + return this.openPanelDialog.getByRole("link", { name }); + } + + streamItem(name: string) { + return this.eventItem(name); + } } diff --git a/e2e/pages/layout/side-nav.ts b/e2e/pages/layout/side-nav.ts index 201134575..8feb8136b 100644 --- a/e2e/pages/layout/side-nav.ts +++ b/e2e/pages/layout/side-nav.ts @@ -1,28 +1,101 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; +import { waitForPOSTResponse } from "../../helpers/playwright"; type Section = "Events" | "Friends" | "Streams"; -/** The sidebar of the site layout, a modal behind a hamburger on narrower viewports. */ +/** + * The sidebar of the site layout, a modal behind a hamburger on narrower viewports. + * + * Its rows are looked up inside the sidebar the viewport shows — the rail, or the + * copy of it the modal renders — so that the same row appearing on the page behind + * it (a tournament also shown as a front page card) is not mistaken for one. + */ export class SideNav { private readonly page: Page; + private readonly root: Locator; readonly locators; constructor(page: Page) { this.page = page; + this.root = page.locator("nav[class*='sideNav']:visible"); this.locators = { collapseButton: page.getByTestId("sidenav-collapse-button"), modalTrigger: page.getByTestId("sidenav-modal-trigger"), - notificationsButton: page.getByTestId("notifications-button"), - viewAllLinks: page.getByRole("link", { name: /View all/ }), + unseenRequestsBadge: page.getByRole("status", { + name: /unseen friend request/, + }), + upcomingDivider: this.root.locator("[class*='upcomingDivider']"), + /** Every row of the sidebar's link lists, in render order: events, then streams. */ + listItems: this.root.locator("a[class*='listLink']"), + friendItems: this.root.locator("button[class*='listButton']"), }; } + /** Where the unseen friend requests badge moves to while the sidebar is collapsed. */ + get collapsedRequestsBadge() { + return this.locators.collapseButton.getByRole("status"); + } + sectionHeading(section: Section) { - return this.page.getByRole("heading", { name: section }); + return this.root.getByRole("heading", { name: section }); + } + + viewAllLink(section: "Events" | "Friends") { + return this.sectionHeading(section) + .locator("xpath=..") + .getByRole("link", { name: /View all/ }); + } + + /** Text shown in place of a section's rows when it has none. */ + emptyText(text: string) { + return this.page.locator("[class*='sideNavEmpty']:visible", { + hasText: text, + }); + } + + eventItem(name: string) { + return this.root.getByRole("link", { name }); + } + + streamItem(name: string) { + return this.eventItem(name); + } + + itemSubtitle(name: string) { + return this.eventItem(name).locator("span[class*='listLinkSubtitle']"); + } + + itemBadge(name: string) { + return this.eventItem(name).locator("span[class*='listLinkBadge']"); + } + + /** The tier an upcoming tournament's stream row is pilled with. */ + itemTier(name: string) { + return this.streamItem(name).getByTestId("confirmed-tier"); + } + + saveStreamButton(streamName: string) { + return this.streamItem(streamName).getByTitle("Save"); + } + + savedStreamIcon(streamName: string) { + return this.streamItem(streamName).locator("svg.lucide-bookmark-check"); + } + + async saveStream(streamName: string) { + await waitForPOSTResponse(this.page, () => + this.saveStreamButton(streamName).click(), + ); + } + + friend(name: string) { + return new FriendRow(this.page, this.root, name); } async toggleCollapse() { - await this.locators.collapseButton.click(); + await waitForPOSTResponse(this.page, () => + this.locators.collapseButton.click(), + ); } async openModal() { @@ -33,3 +106,32 @@ export class SideNav { await this.page.keyboard.press("Escape"); } } + +/** One friend of the friends list, and the menu opening from it. */ +export class FriendRow { + private readonly page: Page; + readonly trigger: Locator; + readonly locators; + + constructor(page: Page, root: Locator, name: string) { + this.page = page; + this.trigger = root.getByRole("button", { name }); + this.locators = { + subtitle: this.trigger.locator("span[class*='listLinkSubtitle']"), + badge: this.trigger.locator("span[class*='listLinkBadge']"), + }; + } + + async openMenu() { + await this.trigger.click(); + } + + /** Menu items render in a portal, so they are found on the page, not in the row. */ + menuItem(name: string) { + return this.page.getByRole("menuitem", { name }); + } + + watchStreamHref() { + return this.menuItem("Watch stream").getAttribute("href"); + } +} diff --git a/e2e/sidebar-events.spec.ts b/e2e/sidebar-events.spec.ts new file mode 100644 index 000000000..87e7ffeef --- /dev/null +++ b/e2e/sidebar-events.spec.ts @@ -0,0 +1,176 @@ +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { EVENTS_PAGE } from "~/utils/urls"; +import { + expect, + expectNoErrorPage, + impersonate, + test, +} from "./helpers/playwright"; +import { expectTopToBottom, inHours, NO_EVENTS_TEXT } from "./helpers/sidebar"; +import { EventsPage } from "./pages/calendar/events-page"; +import { FrontPage } from "./pages/front-page/front-page"; +import { SideNav } from "./pages/layout/side-nav"; + +test.describe("Sidebar events", () => { + test("shows my tournaments, my scrims and saved events, soonest first", async ({ + page, + factories, + }) => { + const saved = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: "Saved Tournament", + startTimes: [inHours(1)], + }); + await factories.SavedCalendarEventFactory.create({ + userId: NZAP_TEST_ID, + tournamentId: saved.id, + }); + + const played = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: "Played Tournament", + startTimes: [inHours(2)], + }); + await factories.TournamentTeamFactory.create({ + tournamentId: played.id, + memberUserIds: [NZAP_TEST_ID], + }); + + await factories.ScrimPostFactory.create({ + startsAt: inHours(3), + users: [{ userId: NZAP_TEST_ID, isOwner: 1 }], + }); + + await factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + name: "Organized Tournament", + startTimes: [inHours(4)], + }); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.locators.listItems).toHaveCount(4); + await expectTopToBottom([ + sideNav.eventItem("Saved Tournament"), + sideNav.eventItem("Played Tournament"), + sideNav.eventItem("Looking for scrim"), + sideNav.eventItem("Organized Tournament"), + ]); + await expect(sideNav.itemSubtitle("Played Tournament")).toHaveText( + /\d{1,2}:\d{2}/, + ); + + await sideNav.eventItem("Played Tournament").click(); + + await expect(page).toHaveURL(new RegExp(`/to/${played.id}`)); + await expectNoErrorPage(page); + }); + + test("scrim rows show their status and link to the right place", async ({ + page, + factories, + }) => { + const [opponentOwner, otherPostOwner] = + await factories.UserFactory.createMany(2); + const opponentTeam = await factories.TeamFactory.create({ + name: "Squid Squad", + memberUserIds: [opponentOwner.id], + }); + + const booked = await factories.ScrimPostFactory.create( + { + startsAt: inHours(1), + teamId: opponentTeam.id, + users: [{ userId: opponentOwner.id, isOwner: 1 }], + }, + { + requests: [ + { users: [{ userId: NZAP_TEST_ID, isOwner: 1 }], isAccepted: true }, + ], + }, + ); + await factories.ScrimPostFactory.create({ + startsAt: inHours(2), + users: [{ userId: NZAP_TEST_ID, isOwner: 1 }], + }); + const pending = await factories.ScrimPostFactory.create( + { + startsAt: inHours(3), + users: [{ userId: otherPostOwner.id, isOwner: 1 }], + }, + { requests: [{ users: [{ userId: NZAP_TEST_ID, isOwner: 1 }] }] }, + ); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.eventItem("vs. Squid Squad")).toBeVisible(); + await expect(sideNav.eventItem("Looking for scrim")).toBeVisible(); + await expect(sideNav.eventItem("Request pending")).toBeVisible(); + + await sideNav.eventItem("vs. Squid Squad").click(); + await expect(page).toHaveURL(`/scrims/${booked.id}`); + await expectNoErrorPage(page); + + await front.goto(); + await sideNav.eventItem("Looking for scrim").click(); + await expect(page).toHaveURL("/scrims"); + + await front.goto(); + await expect(sideNav.eventItem("Request pending")).toHaveAttribute( + "href", + `/scrims?pendingRequestPostId=${pending.id}`, + ); + + await sideNav.eventItem("Request pending").click(); + // the scrims page clears the param once it has scrolled to the post + await expect(page).toHaveURL("/scrims"); + await expectNoErrorPage(page); + }); + + test("caps at five events and View all opens the events page", async ({ + page, + factories, + }) => { + for (const nth of [1, 2, 3, 4, 5, 6, 7]) { + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: `Tournament ${nth}`, + startTimes: [inHours(nth)], + }); + await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [NZAP_TEST_ID], + }); + } + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.locators.listItems).toHaveCount(5); + await expect(sideNav.eventItem("Tournament 5")).toBeVisible(); + await expect(sideNav.eventItem("Tournament 6")).toHaveCount(0); + + await sideNav.viewAllLink("Events").click(); + await expect(page).toHaveURL(EVENTS_PAGE); + + const events = new EventsPage(page); + await expect(events.eventLink("Tournament 6")).toBeVisible(); + await expect(events.eventLink("Tournament 7")).toBeVisible(); + }); + + test("shows an empty state without upcoming events", async ({ page }) => { + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.emptyText(NO_EVENTS_TEXT)).toBeVisible(); + await expect(sideNav.viewAllLink("Events")).toBeVisible(); + }); +}); diff --git a/e2e/sidebar-friends.spec.ts b/e2e/sidebar-friends.spec.ts new file mode 100644 index 000000000..754ca5ed0 --- /dev/null +++ b/e2e/sidebar-friends.spec.ts @@ -0,0 +1,625 @@ +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { + EVENTS_PAGE, + FRIENDS_PAGE, + SENDOUQ_LOOKING_PAGE, + sendouQMatchPage, + tournamentMatchPage, + tournamentSubsPage, + twitchUrl, +} from "~/utils/urls"; +import { + expect, + expectNoErrorPage, + impersonate, + MOBILE_VIEWPORT, + TABLET_VIEWPORT, + test, + waitForPOSTResponse, +} from "./helpers/playwright"; +import { + befriend, + createNamedUsers, + createUserIds, + inHours, + LOGGED_OUT_FRIENDS_TEXT, + NO_FRIENDS_TEXT, + streamAccountOf, +} from "./helpers/sidebar"; +import { + createInProgressMatch, + createSubSeekingTournament, + createTournamentWithByeTeam, +} from "./helpers/tournament"; +import { FriendsPage } from "./pages/friends/friends-page"; +import { FrontPage } from "./pages/front-page/front-page"; +import { MobileNav } from "./pages/layout/mobile-nav"; +import { SideNav } from "./pages/layout/side-nav"; + +const CAST_ACCOUNT = "cast_account"; + +test.describe("Sidebar friends", () => { + test("shows what each friend is doing right now", async ({ + page, + factories, + }) => { + const [idle, queueing, sub, inMatch, groupMate] = await createNamedUsers( + factories, + ["IdleFriend", "QueueFriend", "SubFriend", "MatchFriend", "GroupMate"], + ); + await befriend(factories, [idle.id, queueing.id, sub.id, inMatch.id]); + + await factories.SQGroupFactory.create({ + memberUserIds: [queueing.id, groupMate.id], + }); + + await createSubSeekingTournament(factories, { + name: "Sub Tournament", + subId: sub.id, + }); + + await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: inMatch.id, + }); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + + const idleRow = sideNav.friend("IdleFriend"); + await expect(idleRow.trigger).toBeVisible(); + await expect(idleRow.locators.subtitle).toHaveCount(0); + await expect(idleRow.locators.badge).toHaveCount(0); + + const queueingRow = sideNav.friend("QueueFriend"); + await expect(queueingRow.locators.subtitle).toHaveText("SendouQ"); + await expect(queueingRow.locators.badge).toHaveText("2/4"); + + const subRow = sideNav.friend("SubFriend"); + await expect(subRow.locators.subtitle).toHaveText("Sub Tournament"); + await expect(subRow.locators.badge).toHaveText("1/4"); + + const inMatchRow = sideNav.friend("MatchFriend"); + await expect(inMatchRow.locators.subtitle).toHaveText("Match Tournament"); + await expect(inMatchRow.locators.badge).toHaveText("Match"); + }); + + test("friend menu actions of SendouQ friends land on a real page", async ({ + page, + factories, + }) => { + const [me, idle, queueing, inSendouQMatch] = await createNamedUsers( + factories, + ["SidebarUser", "IdleFriend", "QueueFriend", "MatchFriend"], + ); + await befriend(factories, [idle.id, queueing.id, inSendouQMatch.id], me.id); + + await factories.SQGroupFactory.create({ memberUserIds: [queueing.id] }); + + const match = await factories.SQMatchFactory.create({ + alphaUserIds: [inSendouQMatch.id, ...(await createUserIds(factories, 3))], + bravoUserIds: await createUserIds(factories, 4), + }); + + await impersonate(page, me.id); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + + const idleRow = sideNav.friend("IdleFriend"); + await idleRow.openMenu(); + await expect(idleRow.menuItem("Delete friend")).toHaveCount(0); + await idleRow.menuItem("View user page").click(); + await expect(page).toHaveURL(/\/u\//); + await expectNoErrorPage(page); + + await front.goto(); + const matchRow = sideNav.friend("MatchFriend"); + await matchRow.openMenu(); + await matchRow.menuItem("View match").click(); + await expect(page).toHaveURL(sendouQMatchPage(match.id)); + await expectNoErrorPage(page); + + await front.goto(); + const queueingRow = sideNav.friend("QueueFriend"); + await queueingRow.openMenu(); + await waitForPOSTResponse(page, () => + queueingRow.menuItem("Join SendouQ").click(), + ); + await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); + await expectNoErrorPage(page); + }); + + test("friend menu actions of tournament friends land on a real page", async ({ + page, + factories, + }) => { + const [sub, inMatch, waiting] = await createNamedUsers(factories, [ + "SubFriend", + "MatchFriend", + "WaitingFriend", + ]); + await befriend(factories, [sub.id, inMatch.id, waiting.id]); + + const { tournament: subTournament } = await createSubSeekingTournament( + factories, + { name: "Sub Tournament", subId: sub.id }, + ); + + const { tournament, matchId } = await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: inMatch.id, + }); + const { tournament: waitingTournament } = await createTournamentWithByeTeam( + factories, + { name: "Waiting Tournament", waitingUserId: waiting.id }, + ); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + + const subRow = sideNav.friend("SubFriend"); + await subRow.openMenu(); + await subRow.menuItem("View tournament").click(); + await expect(page).toHaveURL(tournamentSubsPage(subTournament.id)); + await expectNoErrorPage(page); + + await front.goto(); + const waitingRow = sideNav.friend("WaitingFriend"); + await expect(waitingRow.locators.badge).toHaveText("Next"); + await waitingRow.openMenu(); + await waitingRow.menuItem("View tournament").click(); + await expect(page).toHaveURL( + new RegExp(`/to/${waitingTournament.id}(/|$)`), + ); + await expectNoErrorPage(page); + + await front.goto(); + const inMatchRow = sideNav.friend("MatchFriend"); + await inMatchRow.openMenu(); + await inMatchRow.menuItem("View match").click(); + await expect(page).toHaveURL( + tournamentMatchPage({ tournamentId: tournament.id, matchId }), + ); + await expectNoErrorPage(page); + }); + + test("shows four friends on desktop, eight in the mobile panel", async ({ + page, + factories, + }) => { + const names = Array.from( + { length: 10 }, + (_, index) => `Friend${String(index + 1).padStart(2, "0")}`, + ); + const friends = await createNamedUsers(factories, names); + await befriend( + factories, + friends.map((friend) => friend.id), + ); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.locators.friendItems).toHaveCount(4); + + await page.setViewportSize(MOBILE_VIEWPORT); + await front.goto(); + + const mobileNav = new MobileNav(page); + await mobileNav.openPanel("friends"); + await expect(mobileNav.locators.friendItems).toHaveCount(8); + + await mobileNav.locators.viewAllLink.click(); + await expect(page).toHaveURL(FRIENDS_PAGE); + + const friendsPage = new FriendsPage(page); + for (const name of names) { + await expect(friendsPage.friendButton(name)).toBeVisible(); + } + }); + + test("unseen friend requests show as a badge", async ({ + page, + factories, + }) => { + const requesters = await factories.UserFactory.createMany(2); + for (const requester of requesters) { + await factories.FriendRequestFactory.create({ + senderId: requester.id, + receiverId: NZAP_TEST_ID, + }); + } + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.locators.unseenRequestsBadge).toHaveText("2"); + + await sideNav.toggleCollapse(); + await expect(sideNav.collapsedRequestsBadge).toHaveText("2"); + + const friendsPage = new FriendsPage(page); + await friendsPage.goto(); + await friendsPage.acceptRequest(); + + await front.goto(); + await expect(sideNav.locators.unseenRequestsBadge).toHaveCount(0); + }); + + test("shows an empty state for both logged out and friendless users", async ({ + page, + }) => { + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.emptyText(LOGGED_OUT_FRIENDS_TEXT)).toBeVisible(); + await expect(sideNav.viewAllLink("Friends")).toHaveCount(0); + + await impersonate(page, NZAP_TEST_ID); + await front.goto(); + + await expect(sideNav.emptyText(NO_FRIENDS_TEXT)).toBeVisible(); + await expect(sideNav.viewAllLink("Friends")).toBeVisible(); + }); +}); + +test.describe("Sidebar friend streams", () => { + test("picks the best available stream for a friend's tournament match", async ({ + page, + factories, + }) => { + const [friend, teammate, opponent] = await createNamedUsers(factories, [ + "StreamFriend", + "StreamTeammate", + "StreamOpponent", + ]); + await befriend(factories, [friend.id]); + + const { tournament, matchId } = await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: friend.id, + teammateId: teammate.id, + opponentId: opponent.id, + }); + await factories.TournamentFactory.castMatch({ + tournamentId: tournament.id, + matchId, + twitchAccount: CAST_ACCOUNT, + }); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + const friendRow = new SideNav(page).friend("StreamFriend"); + + const expectWatchStream = async (account: string) => { + await front.goto(); + await expect(friendRow.locators.badge).toHaveText("Live"); + await friendRow.openMenu(); + expect(await friendRow.watchStreamHref()).toBe(twitchUrl(account)); + }; + + await factories.LiveStreamFactory.replaceAll([{ userId: friend.id }]); + await expectWatchStream(streamAccountOf(friend.id)); + + await factories.LiveStreamFactory.replaceAll([{ userId: teammate.id }]); + await expectWatchStream(streamAccountOf(teammate.id)); + + await factories.LiveStreamFactory.replaceAll([ + { userId: null, twitch: CAST_ACCOUNT }, + ]); + await expectWatchStream(CAST_ACCOUNT); + + await factories.LiveStreamFactory.replaceAll([{ userId: opponent.id }]); + await expectWatchStream(streamAccountOf(opponent.id)); + + await factories.LiveStreamFactory.replaceAll([ + { userId: friend.id }, + { userId: teammate.id }, + { userId: opponent.id }, + { userId: null, twitch: CAST_ACCOUNT }, + ]); + await expectWatchStream(streamAccountOf(friend.id)); + }); + + test("watch stream opens Twitch, not an in-app error page", async ({ + page, + factories, + }) => { + const [friend] = await createNamedUsers(factories, ["StreamFriend"]); + await befriend(factories, [friend.id]); + + await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: friend.id, + }); + await factories.LiveStreamFactory.replaceAll([{ userId: friend.id }]); + + // stubbed rather than aborted so the popup keeps the URL it was opened with + await page.context().route("https://twitch.tv/**", (route) => + route.fulfill({ + status: 200, + contentType: "text/html", + body: "Twitch", + }), + ); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const friendRow = new SideNav(page).friend("StreamFriend"); + await friendRow.openMenu(); + + const watchStream = friendRow.menuItem("Watch stream"); + await expect(watchStream).toHaveAttribute( + "href", + twitchUrl(streamAccountOf(friend.id)), + ); + await expect(watchStream).toHaveAttribute("target", "_blank"); + await expect(watchStream).toHaveAttribute("rel", "noreferrer"); + + const popupPromise = page + .context() + .waitForEvent("page", { timeout: 5_000 }) + .catch(() => null); + await watchStream.click(); + const popup = await popupPromise; + + if (popup) { + expect(popup.url()).toContain("twitch.tv"); + await popup.close(); + } + + await expect(page).toHaveURL("/"); + await expectNoErrorPage(page); + }); + + test("shows no stream when nobody streams the friend's match", async ({ + page, + factories, + }) => { + // the friend has a Twitch account on their profile throughout: only an + // actually live stream may turn the row live + const [friend, outsider] = await createNamedUsers( + factories, + ["StreamFriend", "Outsider"], + { twitch: "friend_twitch" }, + ); + await befriend(factories, [friend.id]); + + const { tournament, matchId } = await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: friend.id, + }); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const friendRow = new SideNav(page).friend("StreamFriend"); + await expect(friendRow.locators.badge).toHaveText("Match"); + await friendRow.openMenu(); + await expect(friendRow.menuItem("View user page")).toBeVisible(); + await expect(friendRow.menuItem("View match")).toHaveAttribute( + "href", + tournamentMatchPage({ tournamentId: tournament.id, matchId }), + ); + await expect(friendRow.menuItem("Watch stream")).toHaveCount(0); + + await factories.LiveStreamFactory.replaceAll([{ userId: outsider.id }]); + await front.goto(); + + await expect(friendRow.locators.badge).toHaveText("Match"); + await friendRow.openMenu(); + await expect(friendRow.menuItem("Watch stream")).toHaveCount(0); + }); + + test("a casted match whose caster is offline is not live", async ({ + page, + factories, + }) => { + const [friend] = await createNamedUsers(factories, ["StreamFriend"]); + await befriend(factories, [friend.id]); + + const { tournament, matchId } = await createInProgressMatch(factories, { + name: "Match Tournament", + friendId: friend.id, + }); + await factories.TournamentFactory.castMatch({ + tournamentId: tournament.id, + matchId, + twitchAccount: CAST_ACCOUNT, + }); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const friendRow = new SideNav(page).friend("StreamFriend"); + await expect(friendRow.locators.badge).toHaveText("Match"); + await friendRow.openMenu(); + await expect(friendRow.menuItem("Watch stream")).toHaveCount(0); + }); + + test("a friend waiting for their next match is never live", async ({ + page, + factories, + }) => { + const [friend] = await createNamedUsers(factories, ["WaitingFriend"]); + await befriend(factories, [friend.id]); + + await createTournamentWithByeTeam(factories, { + name: "Waiting Tournament", + waitingUserId: friend.id, + }); + await factories.LiveStreamFactory.replaceAll([{ userId: friend.id }]); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const friendRow = new SideNav(page).friend("WaitingFriend"); + await expect(friendRow.locators.badge).toHaveText("Next"); + await friendRow.openMenu(); + await expect(friendRow.menuItem("View tournament")).toBeVisible(); + await expect(friendRow.menuItem("Watch stream")).toHaveCount(0); + }); + + test("shows the stream of a friend's SendouQ match", async ({ + page, + factories, + }) => { + const [friend, streamer] = await createNamedUsers(factories, [ + "MatchFriend", + "MatchStreamer", + ]); + await befriend(factories, [friend.id]); + + const match = await factories.SQMatchFactory.create({ + alphaUserIds: [ + friend.id, + streamer.id, + ...(await createUserIds(factories, 2)), + ], + bravoUserIds: await createUserIds(factories, 4), + }); + await factories.LiveStreamFactory.replaceAll([{ userId: streamer.id }]); + + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const friendRow = new SideNav(page).friend("MatchFriend"); + await expect(friendRow.locators.badge).toHaveText("Live"); + await friendRow.openMenu(); + expect(await friendRow.watchStreamHref()).toBe( + twitchUrl(streamAccountOf(streamer.id)), + ); + + await factories.LiveStreamFactory.replaceAll([]); + await front.goto(); + + await expect(friendRow.locators.badge).toHaveText("Match"); + await friendRow.openMenu(); + await expect(friendRow.menuItem("Watch stream")).toHaveCount(0); + await friendRow.menuItem("View match").click(); + + await expect(page).toHaveURL(sendouQMatchPage(match.id)); + await expectNoErrorPage(page); + }); +}); + +test.describe("Sidebar form factors", () => { + test("collapsing the sidebar sticks across reloads", async ({ page }) => { + await impersonate(page, NZAP_TEST_ID); + const front = new FrontPage(page); + await front.goto(); + + const sideNav = new SideNav(page); + await sideNav.toggleCollapse(); + await expect(sideNav.sectionHeading("Events")).toHaveCount(0); + + await front.goto(); + await expect(sideNav.sectionHeading("Events")).toHaveCount(0); + + await sideNav.toggleCollapse(); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); + + await front.goto(); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); + }); + + test("tablet shows the same sidebar in a modal", async ({ + page, + factories, + }) => { + const [friend] = await createNamedUsers(factories, ["ModalFriend"]); + await befriend(factories, [friend.id]); + + await page.setViewportSize(TABLET_VIEWPORT); + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.sectionHeading("Events")).toHaveCount(0); + + await sideNav.openModal(); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); + await expect(sideNav.sectionHeading("Friends")).toBeVisible(); + await expect(sideNav.sectionHeading("Streams")).toBeVisible(); + await expect(sideNav.friend("ModalFriend").trigger).toBeVisible(); + + await sideNav.closeModal(); + await expect(sideNav.sectionHeading("Events")).toHaveCount(0); + + await sideNav.openModal(); + const friendRow = sideNav.friend("ModalFriend"); + await friendRow.openMenu(); + await friendRow.menuItem("View user page").click(); + + await expect(page).toHaveURL(/\/u\//); + await expect(sideNav.sectionHeading("Events")).toHaveCount(0); + await expectNoErrorPage(page); + }); + + test("mobile panels show the same data as the desktop sidebar", async ({ + page, + factories, + }) => { + const [friend] = await createNamedUsers(factories, ["StreamFriend"]); + await befriend(factories, [friend.id]); + + await createInProgressMatch(factories, { + name: "Streamed Tournament", + friendId: friend.id, + }); + await factories.LiveStreamFactory.replaceAll([{ userId: friend.id }]); + + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: "My Tournament", + startTimes: [inHours(2)], + }); + await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [NZAP_TEST_ID], + }); + + await page.setViewportSize(MOBILE_VIEWPORT); + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const mobileNav = new MobileNav(page); + await mobileNav.openPanel("friends"); + + const friendRow = mobileNav.friend("StreamFriend"); + await expect(friendRow.locators.badge).toHaveText("Live"); + await friendRow.openMenu(); + expect(await friendRow.watchStreamHref()).toBe( + twitchUrl(streamAccountOf(friend.id)), + ); + await page.keyboard.press("Escape"); + + await mobileNav.switchPanel("tourneys"); + await expect(mobileNav.eventItem("My Tournament")).toBeVisible(); + + await mobileNav.switchPanel("menu"); + await expect(mobileNav.locators.streamsHeading).toBeVisible(); + await expect(mobileNav.streamItem("Streamed Tournament")).toBeVisible(); + + await mobileNav.switchPanel("tourneys"); + await mobileNav.locators.viewAllLink.click(); + await expect(page).toHaveURL(EVENTS_PAGE); + await expect(mobileNav.locators.viewAllLink).toHaveCount(0); + }); +}); diff --git a/e2e/sidebar-streams.spec.ts b/e2e/sidebar-streams.spec.ts new file mode 100644 index 000000000..bfced0610 --- /dev/null +++ b/e2e/sidebar-streams.spec.ts @@ -0,0 +1,212 @@ +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import { tournamentStreamsPage, twitchUrl } from "~/utils/urls"; +import { + expect, + expectNoErrorPage, + impersonate, + test, +} from "./helpers/playwright"; +import { + createNamedUsers, + createUserIds, + expectTopToBottom, + inDays, + LOGGED_OUT_FRIENDS_TEXT, + NO_EVENTS_TEXT, + NO_STREAMS_TEXT, +} from "./helpers/sidebar"; +import { createInProgressMatch } from "./helpers/tournament"; +import { FrontPage } from "./pages/front-page/front-page"; +import { SideNav } from "./pages/layout/side-nav"; +import { TournamentStreamsPage } from "./pages/tournament/tournament-streams-page"; + +const XRANK_TWITCH = "xrank_streamer"; +const TOP_PLAYER_XP = 4000; + +test.describe("Sidebar streams", () => { + test("live tournament streams link to the tournament's streams page", async ({ + page, + factories, + }) => { + const [streamer] = await createNamedUsers(factories, [ + "TournamentStreamer", + ]); + const { tournament } = await createInProgressMatch(factories, { + name: "Streamed Tournament", + friendId: streamer.id, + }); + await factories.LiveStreamFactory.replaceAll([{ userId: streamer.id }]); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.itemBadge("Streamed Tournament")).toHaveText("LIVE"); + await expect(sideNav.itemSubtitle("Streamed Tournament")).not.toBeEmpty(); + + await sideNav.streamItem("Streamed Tournament").click(); + await expect(page).toHaveURL(tournamentStreamsPage(tournament.id)); + + await expect( + new TournamentStreamsPage(page).locators.streams.first(), + ).toBeVisible(); + }); + + test("x rank streamers link straight to Twitch", async ({ + page, + factories, + }) => { + const [topPlayer] = await createNamedUsers(factories, ["TopPlayer"], { + twitch: XRANK_TWITCH, + }); + await factories.XRankPlacementFactory.create( + { playerUserId: topPlayer.id, power: TOP_PLAYER_XP }, + { refreshPeakXp: true }, + ); + await factories.LiveStreamFactory.replaceAll([ + { userId: topPlayer.id, twitch: XRANK_TWITCH }, + ]); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.itemSubtitle("TopPlayer")).toHaveText( + String(TOP_PLAYER_XP), + ); + await expect(sideNav.streamItem("TopPlayer")).toHaveAttribute( + "href", + twitchUrl(XRANK_TWITCH), + ); + }); + + test("upcoming tournaments sit under the Upcoming divider and can be saved", async ({ + page, + factories, + }) => { + const [topPlayer] = await createNamedUsers(factories, ["TopPlayer"], { + twitch: XRANK_TWITCH, + }); + await factories.XRankPlacementFactory.create( + { playerUserId: topPlayer.id, power: TOP_PLAYER_XP }, + { refreshPeakXp: true }, + ); + await factories.LiveStreamFactory.replaceAll([ + { userId: topPlayer.id, twitch: XRANK_TWITCH }, + ]); + + await factories.TournamentFactory.create( + { + authorId: ADMIN_ID, + name: "Upcoming Tournament", + startTimes: [inDays(2)], + }, + { tier: 1 }, + ); + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expectTopToBottom([ + sideNav.streamItem("TopPlayer"), + sideNav.locators.upcomingDivider, + sideNav.streamItem("Upcoming Tournament"), + ]); + await expect(sideNav.itemTier("Upcoming Tournament")).toBeVisible(); + + await sideNav.saveStream("Upcoming Tournament"); + await expect(sideNav.savedStreamIcon("Upcoming Tournament")).toBeVisible(); + await expect(sideNav.eventItem("Upcoming Tournament")).toHaveCount(2); + + await sideNav.saveStream("Upcoming Tournament"); + await expect(sideNav.savedStreamIcon("Upcoming Tournament")).toHaveCount(0); + await expect(sideNav.eventItem("Upcoming Tournament")).toHaveCount(1); + }); + + test("shows at most five streams, best first", async ({ + page, + factories, + }) => { + const [highTierStreamer, lowTierStreamer, sendouQStreamer] = + await createNamedUsers(factories, [ + "HighTierStreamer", + "LowTierStreamer", + "SendouQStreamer", + ]); + + await createInProgressMatch(factories, { + name: "High Tier Tournament", + friendId: highTierStreamer.id, + tier: 1, + }); + await createInProgressMatch(factories, { + name: "Low Tier Tournament", + friendId: lowTierStreamer.id, + tier: 3, + }); + await factories.SQMatchFactory.create({ + alphaUserIds: [ + sendouQStreamer.id, + ...(await createUserIds(factories, 3)), + ], + bravoUserIds: await createUserIds(factories, 4), + }); + await factories.LiveStreamFactory.replaceAll([ + { userId: highTierStreamer.id }, + { userId: lowTierStreamer.id }, + { userId: sendouQStreamer.id }, + ]); + + for (const nth of [1, 2, 3]) { + await factories.TournamentFactory.create( + { + authorId: ADMIN_ID, + name: `Upcoming Tournament ${nth}`, + startTimes: [inDays(1)], + }, + { tier: 1 }, + ); + } + + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.locators.listItems).toHaveCount(5); + await expectTopToBottom([ + sideNav.streamItem("High Tier Tournament"), + sideNav.streamItem("Low Tier Tournament"), + sideNav.locators.upcomingDivider, + ]); + }); + + test("shows an empty state without streams", async ({ page }) => { + await impersonate(page, NZAP_TEST_ID); + await new FrontPage(page).goto(); + + await expect(new SideNav(page).emptyText(NO_STREAMS_TEXT)).toBeVisible(); + }); + + test("shows streams to logged out visitors", async ({ page, factories }) => { + const [streamer] = await createNamedUsers(factories, [ + "TournamentStreamer", + ]); + const { tournament } = await createInProgressMatch(factories, { + name: "Streamed Tournament", + friendId: streamer.id, + }); + await factories.LiveStreamFactory.replaceAll([{ userId: streamer.id }]); + + await new FrontPage(page).goto(); + + const sideNav = new SideNav(page); + await expect(sideNav.emptyText(NO_EVENTS_TEXT)).toBeVisible(); + await expect(sideNav.emptyText(LOGGED_OUT_FRIENDS_TEXT)).toBeVisible(); + + await sideNav.streamItem("Streamed Tournament").click(); + await expect(page).toHaveURL(tournamentStreamsPage(tournament.id)); + await expectNoErrorPage(page); + }); +});