Add sidebar e2e tests, fix external stream links and offline casts showing as live

This commit is contained in:
Kalle
2026-08-01 20:55:58 +03:00
parent 5b2d32fbb1
commit ac40e9b0fc
18 changed files with 1445 additions and 77 deletions

View File

@@ -45,7 +45,7 @@ export function Catcher() {
if (isNetworkError) {
return (
<Main>
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Connection error</h2>
<p className="text-center">
@@ -55,7 +55,7 @@ export function Catcher() {
<div className="mt-4 stack sm items-center">
<RefreshPageButton />
</div>
</Main>
</ErrorMain>
);
}
@@ -68,7 +68,7 @@ export function Catcher() {
})();
return (
<Main>
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Error happened</h2>
<p className="text-center">
@@ -84,7 +84,7 @@ export function Catcher() {
</div>
</div>
) : null}
</Main>
</ErrorMain>
);
}
@@ -92,7 +92,7 @@ export function Catcher() {
case 401:
if (!user) {
return (
<Main>
<ErrorMain>
<h2>Authentication required</h2>
<p>This page requires you to be logged in.</p>
<form action={LOG_IN_URL} method="post" className="mt-2">
@@ -100,36 +100,36 @@ export function Catcher() {
Log in via Discord
</SendouButton>
</form>
</Main>
</ErrorMain>
);
}
return (
<Main>
<ErrorMain>
<h2>Error 401 Unauthorized</h2>
<GetHelp />
</Main>
</ErrorMain>
);
case 403:
return (
<Main>
<ErrorMain>
<h2>Error 403 Forbidden</h2>
<p className="text-sm text-lighter font-semi-bold">
Your account doesn't have the required permissions to perform this
action.
</p>
<GetHelp />
</Main>
</ErrorMain>
);
case 404:
return (
<Main>
<ErrorMain>
<h2>Error {error.status} - Page not found</h2>
<GetHelp />
</Main>
</ErrorMain>
);
default:
return (
<Main>
<ErrorMain>
<h2>Error {error.status}</h2>
<GetHelp />
<div className="text-sm text-lighter font-semi-bold">
@@ -142,11 +142,16 @@ export function Catcher() {
? `\n${typeof error.data === "string" ? error.data : JSON.stringify(error.data, null, 2)}`
: null}
</pre>
</Main>
</ErrorMain>
);
}
}
/** Every branch of the error page, marked so tests can assert one is not shown. */
function ErrorMain({ children }: { children: React.ReactNode }) {
return <Main testId="error-page">{children}</Main>;
}
function GetHelp() {
return (
<p className="mt-2">

View File

@@ -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 (
<main
@@ -37,6 +39,7 @@ export const Main = ({
)
}
data-main-breakout={breakoutContainer || undefined}
data-testid={testId}
style={style}
>
{children}

View File

@@ -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. */

View File

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

View File

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

View File

@@ -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();

View File

@@ -235,7 +235,7 @@ function Document({
<body>
{IS_E2E_TEST_RUN && <HydrationTestIndicator />}
<React.StrictMode>
<RouterProvider navigate={navigate} useHref={useHref}>
<RouterProvider navigate={navigate} useHref={useExternalAwareHref}>
<I18nProvider locale={language}>
<SendouToastRegion />
<UnsavedChangesGuard />
@@ -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();

View File

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

View File

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

73
e2e/helpers/sidebar.ts Normal file
View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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<Panel, string> = {
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);
}
}

View File

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

176
e2e/sidebar-events.spec.ts Normal file
View File

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

625
e2e/sidebar-friends.spec.ts Normal file
View File

@@ -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: "<html lang='en'><head><title>Twitch</title></head><body></body></html>",
}),
);
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);
});
});

212
e2e/sidebar-streams.spec.ts Normal file
View File

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