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