Improve E2E test coverage

This commit is contained in:
Kalle
2026-08-22 11:01:39 +03:00
parent 47ea9cf984
commit 19adeef65f
85 changed files with 3161 additions and 57 deletions

View File

@@ -244,6 +244,7 @@ export function BuildCard({
className={styles.smallText}
variant="minimal-destructive"
type="submit"
testId="delete-build"
/>
</FormWithConfirm>
</>

View File

@@ -1,4 +1,6 @@
import { sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { TournamentSettings } from "~/db/tables-json";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as Standings from "~/features/tournament/core/Standings";
@@ -55,6 +57,8 @@ type InsertArgs = Omit<
type Options = {
/** Confirmed tier, as starting the first bracket computes one. */
tier?: TournamentTierNumber;
/** Marks the tournament a league. Leagues have no creation UI, the flag is set straight in the db. */
isLeague?: boolean;
};
/** Brackets to play out fully: one by its idx in the progression, several, or
@@ -84,7 +88,17 @@ export const { create } = defineFactory({
return { id: tournamentId, eventId };
},
applyOptions: async (tournament, { tier }: Options) => {
applyOptions: async (tournament, { tier, isLeague }: Options) => {
if (isLeague) {
await db
.updateTable("Tournament")
.set({
settings: sql<string>`json_set(settings, '$.isLeague', json('true'))`,
})
.where("id", "=", tournament.id)
.execute();
}
if (!tier) return;
await TournamentRepository.upsertDivisionTier({

View File

@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import * as Seasons from "~/features/mmr/core/Seasons";
import { DANGEROUS_setVotingActiveOverride } from "~/features/plus-voting/core/voting-time";
import { refreshCaches } from "../core/refresh-caches.server";
export const action = async ({ request }: ActionFunctionArgs) => {
@@ -12,6 +13,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// leave what the running test set up alone
if (await wantsDevOverridesReset(request)) {
Seasons.DANGEROUS_setSeasonEndedOverride(false);
DANGEROUS_setVotingActiveOverride(false);
}
await refreshCaches();

View File

@@ -0,0 +1,14 @@
import type { ActionFunctionArgs } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { DANGEROUS_setVotingActiveOverride } from "~/features/plus-voting/core/voting-time";
export const action = async ({ request }: ActionFunctionArgs) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
const formData = await request.formData();
DANGEROUS_setVotingActiveOverride(formData.get("active") === "true");
return Response.json(null);
};

View File

@@ -205,7 +205,12 @@ function ImagePreview({
["_action", "DELETE_ART"],
]}
>
<SendouButton icon={<Trash />} variant="destructive" size="small" />
<SendouButton
icon={<Trash />}
variant="destructive"
size="small"
testId="delete-art-button"
/>
</FormWithConfirm>
</div>
</div>
@@ -262,6 +267,7 @@ function ImagePreview({
icon={<Unlink />}
variant="destructive"
size="small"
testId="unlink-art-button"
/>
</FormWithConfirm>
) : null}

View File

@@ -70,7 +70,20 @@ export function isVotingOpen() {
return VOTING_ALWAYS_OPEN || isVotingActive();
}
let votingActiveOverride = false;
/**
* Tests only: makes {@link isVotingActive} (and thus {@link isVotingOpen}) resolve to
* true as if a voting window was ongoing, so tests can cover voting without a real
* window having to be open.
*/
export function DANGEROUS_setVotingActiveOverride(votingActive: boolean) {
votingActiveOverride = votingActive;
}
export function isVotingActive() {
if (votingActiveOverride) return true;
const now = new Date();
for (const season of Seasons.list) {

View File

@@ -279,6 +279,7 @@ function ChangeSortingDialog({
icon={<Trash />}
variant="minimal-destructive"
onPress={deleteLastSorting}
data-testid="delete-sorting-button"
/>
) : null}
</div>

View File

@@ -269,6 +269,7 @@ function AvailableWidgetsList({
variant="outlined"
onPress={() => onAddWidget(widget.id)}
isDisabled={isSelected || isMaxReached}
testId={`add-widget-${widget.id}`}
>
{t("user:widgets.add")}
</SendouButton>

View File

@@ -8,3 +8,9 @@ export const loader = ({ request }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
};
/** Never renders (the loader always redirects or throws) but makes this a page
* route, so the 404 shows the error page instead of a bare empty response. */
export default function CatchAllPage() {
return null;
}

View File

@@ -353,6 +353,10 @@ export default [
route("/refresh-caches", "features/api-private/routes/refresh-caches.ts"),
route("/run-routine", "features/api-private/routes/run-routine.ts"),
route("/seed", "features/api-private/routes/seed.ts"),
route(
"/set-plus-voting-active",
"features/api-private/routes/set-plus-voting-active.ts",
),
route("/users", "features/api-private/routes/users.ts"),
route("/scanner", "features/scanner/routes/scanner.tsx"),

172
e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,172 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { addHours, addYears } from "date-fns";
import { NZAP_TEST_DISCORD_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { SPLATOON_3_XP_BADGE_VALUES } from "~/features/badges/badges-constants";
import {
expect,
impersonate,
isNotVisible,
navigate,
test,
} from "./helpers/playwright";
import { AdminActionsPage } from "./pages/admin/admin-actions-page";
import { AdminStreamsPage } from "./pages/admin/admin-streams-page";
import { ApiPage } from "./pages/api/api-page";
import { NewArtPage } from "./pages/art/new-art-page";
import { TopRightButtons } from "./pages/layout/top-right-buttons";
import { NewOrganizationPage } from "./pages/org/new-organization-page";
import { UserPage } from "./pages/user/user-page";
import { NewVodPage } from "./pages/vods/new-vod-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = path.join(__dirname, "fixtures/test-image.png");
const ROLE_TARGET = {
discordId: "223456789012345678",
discordName: "RoleTarget",
};
const KEEPER = { discordId: "323456789012345678", discordName: "KeeperUser" };
const LEAVER = { discordId: "423456789012345678", discordName: "LeaverUser" };
const FRIEND_CODE = "0123-4567-8901";
// the wiped database makes the placement's player the first SplatoonPlayer row
const LINKED_PLAYER_ID = 1;
test.describe("Admin panel", () => {
test("grants roles, friend code, API access and patron status to a user", async ({
page,
factories,
}) => {
const target = await factories.UserFactory.create({
...ROLE_TARGET,
friendCode: null,
});
const api = new ApiPage(page);
const topRightButtons = new TopRightButtons(page);
await impersonate(page, target.id);
await api.goto();
await expect(api.locators.noAccessMessage).toBeVisible();
await navigate({ page, url: "/" });
await expect(topRightButtons.locators.supportLink).toBeVisible();
await impersonate(page, ADMIN_ID);
const adminActions = new AdminActionsPage(page);
await adminActions.goto();
await adminActions.updateFriendCode(ROLE_TARGET.discordName, FRIEND_CODE);
await adminActions.giveArtist(ROLE_TARGET.discordName);
await adminActions.giveVideoAdder(ROLE_TARGET.discordName);
await adminActions.giveApiAccess(ROLE_TARGET.discordName);
await adminActions.openFriendCodeLookUp();
await adminActions.searchFriendCode(FRIEND_CODE);
await expect(
adminActions.foundUserLink(ROLE_TARGET.discordName),
).toBeVisible();
await impersonate(page, target.id);
const newArt = new NewArtPage(page);
await newArt.goto();
await expect(newArt.locators.descriptionInput).toBeVisible();
const newVod = new NewVodPage(page);
await newVod.goto();
await expect(newVod.locators.addMatchButton).toBeVisible();
await api.goto();
await isNotVisible(api.locators.noAccessMessage);
const token = await api.generateToken("read");
expect(token.length).toBeGreaterThan(0);
// not a tournament organizer yet
const newOrganization = new NewOrganizationPage(page);
await newOrganization.goto();
await expect(newOrganization.locators.noPermissionsAlert).toBeVisible();
await impersonate(page, ADMIN_ID);
await adminActions.goto();
await adminActions.giveTournamentOrganizer(ROLE_TARGET.discordName);
// tier one so the tournament organizer grant above stays the only source of that role
await adminActions.forcePatron(ROLE_TARGET.discordName, {
tier: "Support",
expiresAt: addYears(new Date(), 1),
});
await impersonate(page, target.id);
await newOrganization.goto();
await expect(newOrganization.locators.heading).toBeVisible();
await navigate({ page, url: "/" });
await isNotVisible(topRightButtons.locators.supportLink);
});
test("links a player, refreshes plus tiers and migrates an account", async ({
page,
factories,
}) => {
// linking a player syncs XP badges, which requires every XP badge to exist
for (const value of SPLATOON_3_XP_BADGE_VALUES) {
await factories.BadgeFactory.create({ code: String(value) });
}
await factories.XRankPlacementFactory.create({
playerSplId: "e2e-unlinked-player",
name: "PlacedPlayer",
});
const keeper = await factories.UserFactory.create({ ...KEEPER });
await factories.UserFactory.create({ ...LEAVER });
await factories.PlusVoteFactory.create({
authorId: ADMIN_ID,
votedId: keeper.id,
});
await impersonate(page, ADMIN_ID);
const adminActions = new AdminActionsPage(page);
await adminActions.goto();
await adminActions.linkPlayer("N-ZAP", LINKED_PLAYER_ID);
await adminActions.refreshPlusTiers();
await adminActions.migrateUser({
oldUserName: KEEPER.discordName,
newUserName: LEAVER.discordName,
});
await expect(
await adminActions.userSearchSuggestion(KEEPER.discordName),
).toContainText("+1");
const userPage = new UserPage(page);
await userPage.goto(NZAP_TEST_DISCORD_ID);
await expect(userPage.locators.placementsBox).toBeVisible();
const playerPage = await userPage.openPlacements();
await expect(playerPage.locators.heading).toBeVisible();
// the old user kept their account, now reached via the new user's Discord id
await userPage.goto(LEAVER.discordId);
await expect(userPage.usernameHeading(KEEPER.discordName)).toBeVisible();
});
test("adds and deletes an external stream", async ({ page }) => {
await impersonate(page, ADMIN_ID);
const streams = new AdminStreamsPage(page);
await streams.goto();
await expect(streams.locators.addStreamHeading).toBeVisible();
await expect(streams.locators.noStreams).toBeVisible();
await streams.createStream({
name: "E2E Stream",
url: "https://www.twitch.tv/sendou",
startTime: addHours(new Date(), 1),
logoPath: TEST_IMAGE_PATH,
});
await expect(streams.streamLink("E2E Stream")).toBeVisible();
await isNotVisible(streams.locators.noStreams);
await streams.deleteStream("E2E Stream");
await expect(streams.locators.noStreams).toBeVisible();
});
});

View File

@@ -1,7 +1,8 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { NewArtPage } from "./pages/art/new-art-page";
import { UserArtPage } from "./pages/art/user-art-page";
import { ImageValidationPage } from "./pages/img-upload/image-validation-page";
@@ -52,12 +53,16 @@ test.describe("Art", () => {
expect(box!.height).toBeGreaterThan(0);
});
test("edits already uploaded art keeping its image", async ({
test("edits own art, unlinks from art made of them and deletes own art", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(NZAP_TEST_ID, { roles: ["ARTIST"] });
const art = await factories.ArtFactory.create({ authorId: NZAP_TEST_ID });
await factories.ArtFactory.create({
authorId: ADMIN_ID,
linkedUsers: [NZAP_TEST_ID],
});
await impersonate(page, NZAP_TEST_ID);
@@ -80,5 +85,18 @@ test.describe("Art", () => {
// the saved description is loaded back into the form for editing
await newArt.goto(art.id);
await expect(newArt.locators.descriptionInput).toHaveValue("Squid drawing");
// both their own art and the art they are tagged in show on their page
await userArt.goto(NZAP_TEST_DISCORD_ID);
await expect(userArt.locators.images).toHaveCount(2);
await userArt.unlinkFromArt();
await expect(userArt.locators.images).toHaveCount(1);
await isNotVisible(userArt.locators.unlinkButton);
await userArt.deleteArt();
await expect(userArt.locators.images).toHaveCount(0);
});
});

71
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,71 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import { expect, isNotVisible, navigate, test } from "./helpers/playwright";
import { DiscordAuthorizeInterceptor } from "./pages/auth/discord-authorize";
import { LogInLinkPage } from "./pages/auth/log-in-link-page";
import { FrontPage } from "./pages/front-page/front-page";
import { ErrorPage } from "./pages/layout/error-page";
import { SideNav } from "./pages/layout/side-nav";
import { SettingsPage } from "./pages/settings/settings-page";
test.describe("Auth", () => {
test("logs in via a log in link and logs out from the settings page", async ({
page,
factories,
}) => {
const logInLink = await factories.LogInLinkFactory.create({
userId: NZAP_TEST_ID,
});
const logInLinkPage = new LogInLinkPage(page);
const sideNav = new SideNav(page);
await logInLinkPage.goto(logInLink.code);
await expect(page).toHaveURL("/");
await expect(sideNav.locators.footerUsername).toHaveText("N-ZAP");
await isNotVisible(sideNav.locators.logInButton);
const settings = new SettingsPage(page);
await settings.goto();
await settings.logOut();
await expect(sideNav.locators.logInButton).toBeVisible();
await isNotVisible(sideNav.locators.footerUsername);
const errorPage = new ErrorPage(page);
await navigate({ page, url: NOTIFICATIONS_URL });
await expect(errorPage.heading("Authentication required")).toBeVisible();
// the first log in consumed the single use link
const reusedLink = await logInLinkPage.fetchResponse(logInLink.code);
expect(reusedLink.status).toBe(400);
expect(reusedLink.body).toContain("Invalid log in link");
});
test("log in button starts the Discord OAuth flow", async ({
page,
context,
}) => {
const discord = new DiscordAuthorizeInterceptor();
await discord.install(context);
const frontPage = new FrontPage(page);
await frontPage.goto();
const sideNav = new SideNav(page);
await sideNav.locators.logInButton.click();
await discord.waitForCapture();
expect(discord.authorizeUrl.href).toMatch(
/discord\.com\/(api\/)?oauth2\/authorize/,
);
expect(discord.param("client_id")).toBe("123");
expect(discord.param("response_type")).toBe("code");
expect(discord.param("scope")).toContain("identify");
expect(discord.param("state")).toBeTruthy();
expect(discord.param("redirect_uri")).toMatch(
/^http:\/\/localhost:\d+\/auth\/callback$/,
);
});
});

View File

@@ -2,9 +2,11 @@ import { subDays } from "date-fns";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types";
import { expect, impersonate, test } from "./helpers/playwright";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { BuildFormPage } from "./pages/builds/build-form-page";
import { BuildStatsPage } from "./pages/builds/build-stats-page";
import { BuildsPage } from "./pages/builds/builds-page";
import { PopularBuildsPage } from "./pages/builds/popular-builds-page";
import { UserBuildsPage } from "./pages/builds/user-builds-page";
import { WeaponBuildsPage } from "./pages/builds/weapon-builds-page";
@@ -20,6 +22,19 @@ const ABILITIES_WITHOUT_ISM: BuildAbilitiesTuple = [
["QR", "QR", "QR", "QR"],
];
// per build: CB 10 AP, ISM 9 AP, SSU 19 AP, RSU 19 AP
const STATS_ABILITIES: BuildAbilitiesTuple = [
["CB", "ISM", "ISM", "ISM"],
["SSU", "SSU", "SSU", "SSU"],
["RSU", "RSU", "RSU", "RSU"],
];
const OTHER_WEAPON_ABILITIES: BuildAbilitiesTuple = [
["QR", "QR", "QR", "QR"],
["QSJ", "QSJ", "QSJ", "QSJ"],
["SS", "SS", "SS", "SS"],
];
test.describe("Builds", () => {
test("adds a build", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
@@ -131,4 +146,94 @@ test.describe("Builds", () => {
// no change in count since all builds in test data are new
await expect(weaponBuilds.locators.buildCards).toHaveCount(5);
});
test("aggregates builds into ability stats and popular builds", async ({
page,
factories,
}) => {
await factories.BuildFactory.createMany(3, {
ownerId: ADMIN_ID,
weaponSplIds: [40],
abilities: STATS_ABILITIES,
});
await factories.BuildFactory.create({
ownerId: NZAP_TEST_ID,
weaponSplIds: [40],
abilities: STATS_ABILITIES,
});
// a build for another weapon so site-wide stats differ from the weapon's
await factories.BuildFactory.create({
ownerId: NZAP_TEST_ID,
weaponSplIds: [10],
abilities: OTHER_WEAPON_ABILITIES,
});
const weaponBuilds = new WeaponBuildsPage(page);
await new BuildsPage(page).openWeapon(40);
await expect(weaponBuilds.locators.buildCards).toHaveCount(4);
await weaponBuilds.locators.abilityStatsLink.click();
const buildStats = new BuildStatsPage(page);
await expect(buildStats.buildsCountTitle(4, "Splattershot")).toBeVisible();
// SSU and RSU weapon averages
await expect(buildStats.apAverage(19)).toHaveCount(2);
// ISM weapon average
await expect(buildStats.apAverage(9)).toHaveCount(1);
// CB is in every Splattershot build but in 4 of the 5 builds site-wide
await expect(buildStats.abilityPercentage(100)).toHaveCount(1);
await expect(buildStats.abilityPercentage(80)).toHaveCount(1);
const popularBuilds = new PopularBuildsPage(page);
await popularBuilds.goto("splattershot");
// admin's identical builds count once, N-ZAP's brings the signature to ×2
await expect(popularBuilds.placement(1)).toBeVisible();
await expect(popularBuilds.buildCount(2)).toBeVisible();
await expect(popularBuilds.ability("CB")).toBeVisible();
await expect(popularBuilds.abilityPoints(19)).toHaveCount(2);
await expect(popularBuilds.abilityPoints(9)).toHaveCount(1);
await isNotVisible(popularBuilds.placement(2));
});
test("edits build title, changes sorting and deletes a build", async ({
page,
factories,
}) => {
const olderBuild = await factories.BuildFactory.create({
ownerId: ADMIN_ID,
title: "Alpha Build",
});
await factories.BuildFactory.create({
ownerId: ADMIN_ID,
title: "Mid Build",
});
await factories.backdate("Build", olderBuild.id, {
updatedAt: subDays(new Date(), 1),
});
await impersonate(page);
const userBuilds = new UserBuildsPage(page);
await userBuilds.goto(ADMIN_DISCORD_ID);
await expect(userBuilds.buildCard(0).title).toContainText("Mid Build");
const buildForm = await userBuilds.editBuild(0);
await buildForm.form.fill("title", "Zulu Build");
await buildForm.form.submit();
await expect(userBuilds.buildCard(0).title).toContainText("Zulu Build");
await expect(userBuilds.buildCard(1).title).toContainText("Alpha Build");
await userBuilds.changeSortingTo("ALPHABETICAL_TITLE");
await expect(userBuilds.buildCard(0).title).toContainText("Alpha Build");
await expect(userBuilds.buildCard(1).title).toContainText("Zulu Build");
await userBuilds.deleteBuild(0);
await expect(userBuilds.locators.buildCards).toHaveCount(1);
await expect(userBuilds.buildCard(0).title).toContainText("Zulu Build");
});
});

View File

@@ -152,7 +152,7 @@ test.describe("Calendar", () => {
}
});
test("creates a new calendar event", async ({ page }) => {
test("creates, edits and deletes a calendar event", async ({ page }) => {
await impersonate(page);
const newEvent = new CalendarNewEventPage(page);
@@ -165,6 +165,36 @@ test.describe("Calendar", () => {
await newEvent.form.submit();
await expect(page).toHaveURL(/\/calendar\/\d+/);
const eventId = Number(page.url().match(/\/calendar\/(\d+)/)?.[1]);
const calendarEvent = new CalendarEventPage(page);
const editedDate = new Date(2027, 0, 20, 18, 0);
const editEvent = await calendarEvent.openEdit();
await editEvent.form.fill("name", "Renamed Calendar Event");
await editEvent.setFirstDate(editedDate);
await editEvent.save();
await expect(page).toHaveURL(calendarEventPage(eventId));
await expect(calendarEvent.startTime(editedDate)).toBeVisible();
// the edited name and date land on the calendar
const calendar = new CalendarPage(page);
const editedDateWeek = { day: 20, month: 0, year: 2027 };
await calendar.goto(editedDateWeek);
await expect(
calendar.tournamentCard("Renamed Calendar Event"),
).toBeVisible();
await calendarEvent.goto(eventId);
await calendarEvent.delete();
await expect(page).toHaveURL(/\/calendar$/);
await calendar.goto(editedDateWeek);
await isNotVisible(calendar.tournamentCard("Renamed Calendar Event"));
});
test("creates a new tournament with a map pool and follow-up bracket", async ({

View File

@@ -28,6 +28,14 @@ test.describe("Friends", () => {
// having been opened
await expect(notifications.locators.bellDot).toBeHidden();
await notifications.open();
await expect(
notifications.notification("Sendou sent you a friend request"),
).toBeVisible();
await notifications.close();
await friends.friend("Sendou").deleteFriend();
await expect(friends.locators.noFriendsText).toBeVisible();

View File

@@ -40,30 +40,60 @@ export async function loadFactories(parallelIndex: number) {
CalendarEventFactory: await import(
"~/db/seed/factories/CalendarEventFactory"
),
CalendarEventResultFactory: await import(
"~/db/seed/factories/CalendarEventResultFactory"
),
FriendRequestFactory: await import(
"~/db/seed/factories/FriendRequestFactory"
),
FriendshipFactory: await import("~/db/seed/factories/FriendshipFactory"),
GroupMatchContinueVoteFactory: await import(
"~/db/seed/factories/GroupMatchContinueVoteFactory"
),
ImageFactory: await import("~/db/seed/factories/ImageFactory"),
LFGPostFactory: await import("~/db/seed/factories/LFGPostFactory"),
LiveStreamFactory: await import("~/db/seed/factories/LiveStreamFactory"),
LogInLinkFactory: await import("~/db/seed/factories/LogInLinkFactory"),
NotificationFactory: await import(
"~/db/seed/factories/NotificationFactory"
),
PlusSuggestionFactory: await import(
"~/db/seed/factories/PlusSuggestionFactory"
),
PlusVoteFactory: await import("~/db/seed/factories/PlusVoteFactory"),
ResultHighlightFactory: await import(
"~/db/seed/factories/ResultHighlightFactory"
),
SavedCalendarEventFactory: await import(
"~/db/seed/factories/SavedCalendarEventFactory"
),
ScrimPostFactory: await import("~/db/seed/factories/ScrimPostFactory"),
SkillFactory: await import("~/db/seed/factories/SkillFactory"),
SplatoonRotationFactory: await import(
"~/db/seed/factories/SplatoonRotationFactory"
),
SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"),
SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"),
SQReadyCheckFactory: await import(
"~/db/seed/factories/SQReadyCheckFactory"
),
SQReportedWeaponFactory: await import(
"~/db/seed/factories/SQReportedWeaponFactory"
),
TeamFactory: await import("~/db/seed/factories/TeamFactory"),
TournamentFactory: await import("~/db/seed/factories/TournamentFactory"),
TournamentLFGTeamFactory: await import(
"~/db/seed/factories/TournamentLFGTeamFactory"
),
TournamentOrganizationFactory: await import(
"~/db/seed/factories/TournamentOrganizationFactory"
),
TournamentReportedWeaponFactory: await import(
"~/db/seed/factories/TournamentReportedWeaponFactory"
),
TournamentStreamerFactory: await import(
"~/db/seed/factories/TournamentStreamerFactory"
),
TournamentTeamFactory: await import(
"~/db/seed/factories/TournamentTeamFactory"
),

View File

@@ -187,6 +187,30 @@ export async function selectTournament({
await item.first().click();
}
/** Fills a React Aria datetime field's segments, targeting them by the field's label. */
export async function fillDateTimeField({
scope,
label,
date,
}: {
scope: Locator;
label: string;
date: Date;
}) {
const fillSegment = (segment: string, value: string) =>
scope
.getByRole("spinbutton", { name: new RegExp(`^${segment}, ${label}`) })
.fill(value);
const hours = date.getHours();
await fillSegment("year", String(date.getFullYear()));
await fillSegment("month", String(date.getMonth() + 1));
await fillSegment("day", String(date.getDate()));
await fillSegment("hour", String(hours % 12 || 12));
await fillSegment("minute", String(date.getMinutes()).padStart(2, "0"));
await fillSegment("AM/PM", hours >= 12 ? "PM" : "AM");
}
/** page.goto that waits for the page to be hydrated before proceeding */
export async function navigate({ page, url }: { page: Page; url: string }) {
await flushIfDirty(page);
@@ -225,6 +249,24 @@ export async function endSeason(page: Page) {
}
}
/**
* Makes the worker's server resolve Plus Server voting as active, so tests can
* cover the voting window. Undone before the next test starts.
*/
export async function setPlusVotingActive(page: Page, active: boolean) {
const response = await retryPost(
page,
"setPlusVotingActive",
"/set-plus-voting-active",
{ form: { active: String(active) } },
);
if (!response?.ok()) {
throw new Error(
`Setting plus voting active failed with status ${response?.status()}`,
);
}
}
/** Runs the named server Routine (normally cron-driven) in the worker's server process. */
export async function runRoutine(page: Page, name: string) {
const response = await retryPost(page, "runRoutine", "/run-routine", {

155
e2e/leaderboards.spec.ts Normal file
View File

@@ -0,0 +1,155 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import type { Factories } from "./helpers/factories";
import {
endSeason,
expect,
expectNoErrorPage,
isNotVisible,
test,
} from "./helpers/playwright";
import { LeaderboardsPage } from "./pages/leaderboards/leaderboards-page";
const CURRENT_SEASON = 1;
/** Splattershot, making the admin count for the Shooters category leaderboard. */
const ADMIN_WEAPON_SPL_ID = 40;
/** Name the season's top ten showcase renders for the first placement, from top-ten.json. */
const TOP_TEN_FIRST_PLACE_NAME = "Jared";
test.describe("Leaderboards", () => {
test("shows qualified players, teams and X Battle placements across the leaderboard views and the season boundary", async ({
page,
factories,
}) => {
await seedLeaderboards(factories);
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto();
await expectNoErrorPage(page);
// top ten renders in the showcase format, the 11th entry as a normal row
await expect(
leaderboards.entryName(TOP_TEN_FIRST_PLACE_NAME),
).toBeVisible();
await expect(leaderboards.entryName("Tail Ender 3")).toBeVisible();
await expect(leaderboards.locators.updateInfoText).toBeVisible();
// N-ZAP has the highest rating of the pool but one match too few to qualify
await isNotVisible(leaderboards.entryName("N-ZAP"));
// weapon category leaderboard only has the player with enough reported weapons
await leaderboards.filterChip("Shooters").click();
await expect(leaderboards.entryName("Sendou")).toBeVisible();
await isNotVisible(leaderboards.entryName("Alpha Mate 1"));
await leaderboards.tab("Teams").click();
await expectNoErrorPage(page);
await expect(leaderboards.entryName("Alpha Mate 1")).toBeVisible();
await expect(leaderboards.entryName("Bravo Foe 4")).toBeVisible();
await leaderboards.filterChip("All rosters").click();
await expect(leaderboards.filterChipRadio("All rosters")).toBeChecked();
await expect(leaderboards.entryName("Alpha Mate 1")).toBeVisible();
await expect(leaderboards.entryName("Bravo Foe 4")).toBeVisible();
await leaderboards.tab("X Battle").click();
await expectNoErrorPage(page);
await expect(leaderboards.entryName("XP Zones Ace")).toBeVisible();
await expect(leaderboards.entryName("XP Tower Ace")).toBeVisible();
await leaderboards.filterChip("Tower Control").click();
await isNotVisible(leaderboards.entryName("XP Zones Ace"));
await expect(leaderboards.entryName("XP Tower Ace")).toBeVisible();
await leaderboards.selectXPWeapon("Splattershot");
await isNotVisible(leaderboards.entryName("XP Tower Ace"));
await expect(leaderboards.entryName("XP Zones Ace")).toBeVisible();
// once the season ends the page defaults to the previous, empty season
await endSeason(page);
await leaderboards.goto();
await expectNoErrorPage(page);
await expect(leaderboards.locators.noPlayersText).toBeVisible();
// the ended season stays browsable via the season select, now finalized
await leaderboards.selectSeason(CURRENT_SEASON);
await expect(
leaderboards.entryName(TOP_TEN_FIRST_PLACE_NAME),
).toBeVisible();
await expect(leaderboards.entryName("Tail Ender 3")).toBeVisible();
});
});
/**
* Fills the current season's leaderboards: two full rosters playing each other for
* exactly the qualifying match count (admin's alpha roster winning every set), three
* low-rated players qualifying via their skill rows so the board reaches past the top
* ten showcase, N-ZAP left one match short of qualifying despite the highest rating,
* enough reported weapons to give the admin a Shooters entry, and two X Battle
* placements in different modes and weapons.
*/
async function seedLeaderboards(factories: Factories) {
const mates = await factories.UserFactory.createMany(3, (i) => ({
discordName: `Alpha Mate ${i + 1}`,
}));
const enemies = await factories.UserFactory.createMany(4, (i) => ({
discordName: `Bravo Foe ${i + 1}`,
}));
const alphaUserIds = [ADMIN_ID, ...mates.map((mate) => mate.id)];
const bravoUserIds = enemies.map((enemy) => enemy.id);
const matches = [];
for (let i = 0; i < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; i++) {
matches.push(
await factories.SQMatchFactory.create(
{ alphaUserIds, bravoUserIds },
{ isConcluded: true },
),
);
}
// one more weapon report than the qualifying match count, as the category needs
for (const match of matches.slice(0, 2)) {
await factories.SQReportedWeaponFactory.createMany(4, (mapIndex) => ({
groupMatchId: match.id,
mapIndex,
userId: ADMIN_ID,
weaponSplId: ADMIN_WEAPON_SPL_ID,
}));
}
const tailEnders = await factories.UserFactory.createMany(3, (i) => ({
discordName: `Tail Ender ${i + 1}`,
}));
for (const [i, tailEnder] of tailEnders.entries()) {
await factories.SkillFactory.create(
{ userId: tailEnder.id, mu: 3 - i, sigma: 10 },
{ matchesCount: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD },
);
}
// guaranteed last were it wrongly included, so its username would show as a row
await factories.SkillFactory.create(
{ userId: NZAP_TEST_ID, mu: 1, sigma: 12 },
{ matchesCount: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD - 1 },
);
await factories.XRankPlacementFactory.create({
playerUserId: ADMIN_ID,
mode: "SZ",
weaponSplId: ADMIN_WEAPON_SPL_ID,
power: 3000,
name: "XP Zones Ace",
});
await factories.XRankPlacementFactory.create({
playerSplId: "xp-tower-ace",
mode: "TC",
weaponSplId: 2010,
power: 2800,
name: "XP Tower Ace",
});
}

61
e2e/map-tools.spec.ts Normal file
View File

@@ -0,0 +1,61 @@
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { expect, expectNoErrorPage, test } from "./helpers/playwright";
import { MapListGeneratorPage } from "./pages/maps/map-list-generator-page";
import { MapPlannerPage } from "./pages/plans/map-planner-page";
const GENERATED_MAP_LIST_LENGTH = stageIds.length * 2;
test.describe("Map List Generator", () => {
test("generates a map list from a custom map pool", async ({ page }) => {
const mapListPage = new MapListGeneratorPage(page);
await mapListPage.goto();
await mapListPage.clearMapPool();
await expect(mapListPage.locators.createMapListButton).toBeDisabled();
await mapListPage.toggleMode("Museum d'Alfonsino", "Splat Zones");
await mapListPage.toggleMode("Hagglefish Market", "Splat Zones");
await mapListPage.toggleMode("Manta Maria", "Tower Control");
await expect(
mapListPage.modeButton("Manta Maria", "Tower Control"),
).toHaveAttribute("aria-pressed", "true");
await mapListPage.reloadWithPersistedPool();
await expect(
mapListPage.modeButton("Museum d'Alfonsino", "Splat Zones"),
).toHaveAttribute("aria-pressed", "true");
await mapListPage.createMapList();
const items = mapListPage.locators.generatedMapListItems;
await expect(items).toHaveCount(GENERATED_MAP_LIST_LENGTH);
await expect(items).toHaveText(
Array.from(
{ length: GENERATED_MAP_LIST_LENGTH },
() => /^(SZ (Museum d'Alfonsino|Hagglefish Market)|TC Manta Maria)$/,
),
);
await expect(
items.filter({ hasText: "Manta Maria" }).first(),
).toBeVisible();
});
});
test.describe("Map Planner", () => {
test("sets a stage background and adds a weapon to the canvas", async ({
page,
}) => {
const planner = new MapPlannerPage(page);
await planner.goto();
await expectNoErrorPage(page);
await expect(planner.locators.imageShapes).toHaveCount(0);
await planner.setBackground("Museum d'Alfonsino");
await expect(planner.locators.imageShapes).toHaveCount(1);
await planner.openWeaponCategory("Shooters");
await planner.dragWeaponToCanvas("Splattershot");
await expect(planner.locators.imageShapes).toHaveCount(2);
});
});

88
e2e/mobile-smoke.spec.ts Normal file
View File

@@ -0,0 +1,88 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import {
expect,
expectNoErrorPage,
impersonate,
MOBILE_VIEWPORT,
test,
} from "./helpers/playwright";
import { inHours } from "./helpers/sidebar";
import { BuildsPage } from "./pages/builds/builds-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
import { FrontPage } from "./pages/front-page/front-page";
import { MobileNav } from "./pages/layout/mobile-nav";
import { SettingsPage } from "./pages/settings/settings-page";
import { TournamentPage } from "./pages/tournament/tournament-page";
import { UserPage } from "./pages/user/user-page";
const SPLATTERSHOT_ID = 40;
test.describe("Mobile smoke", () => {
test("moves through common pages via the mobile nav and saves a setting", async ({
page,
factories,
}) => {
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: "Mobile Cup",
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);
const front = new FrontPage(page);
await front.goto();
await expect(front.locators.welcomeBanner).toBeVisible();
const mobileNav = new MobileNav(page);
await mobileNav.openPanel("tourneys");
await mobileNav.eventItem("Mobile Cup").click();
await expect(page).toHaveURL(new RegExp(`/to/${tournament.id}`));
await expectNoErrorPage(page);
await expect(
new TournamentPage(page).nameHeading("Mobile Cup"),
).toBeVisible();
await mobileNav.openPanel("menu");
await mobileNav.menuLink("Builds").click();
await expect(page).toHaveURL(/\/builds/);
await expectNoErrorPage(page);
await expect(
new BuildsPage(page).weaponLink(SPLATTERSHOT_ID),
).toBeVisible();
await mobileNav.openPanel("menu");
await mobileNav.menuLink("Calendar").click();
await expect(page).toHaveURL(/\/calendar/);
await expectNoErrorPage(page);
await expect(
new CalendarPage(page).tournamentCard("Mobile Cup"),
).toBeVisible();
await mobileNav.openPanel("you");
await mobileNav.locators.youPanelUsername.click();
await expect(page).toHaveURL(/\/u\//);
await expectNoErrorPage(page);
const userPage = new UserPage(page);
await expect(userPage.locators.editProfileButton).toBeVisible();
await mobileNav.openPanel("you");
await mobileNav.locators.youPanelSettingsLink.click();
await expect(page).toHaveURL(/\/settings/);
await expectNoErrorPage(page);
const settings = new SettingsPage(page);
await settings.selectTab("Preferences");
await settings.checkDisableBuildAbilitySortingToggle();
await settings.reload();
await expect(settings.locators.buildAbilitySortingToggle).toBeChecked();
});
});

View File

@@ -1,6 +1,7 @@
import { addHours } from "date-fns";
import { addHours, subMonths } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { ESTABLISHED_ORG } from "~/features/tournament-organization/tournament-organization-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
expect,
@@ -172,6 +173,59 @@ test.describe("Tournament Organization", () => {
);
});
test("shows org admin the stats counting a played tournament's participants", async ({
page,
factories,
}) => {
const PLAYER_COUNT = 8;
const org = await factories.TournamentOrganizationFactory.create({
ownerId: ADMIN_ID,
name: ORGANIZATION_NAME,
});
const players = await factories.UserFactory.createMany(PLAYER_COUNT - 1);
const playerIds = [ADMIN_ID, ...players.map((player) => player.id)];
const teamRosters = [playerIds.slice(0, 4), playerIds.slice(4, 8)];
// only full months before the current one count towards the stats
const lastMonth = subMonths(new Date(), 1);
await factories.TournamentFactory.createPlayed(
{
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
organizationId: org.id,
startTimes: [dateToDatabaseTimestamp(lastMonth)],
},
{ teamRosters },
);
const organization = new OrganizationPage(page);
// a non-member sees no stats button
await impersonate(page, NZAP_TEST_ID);
await organization.goto(org.slug);
await isNotVisible(organization.locators.statsButton);
await impersonate(page, ADMIN_ID);
await organization.goto(org.slug);
const stats = await organization.openStats();
const expectedAverage = (
PLAYER_COUNT / ESTABLISHED_ORG.MONTHS_CONSIDERED
).toFixed(1);
await expect(stats.locators.establishedStatus).toContainText(
expectedAverage,
);
await expect(stats.locators.establishedStatus).toContainText(
`/ ${ESTABLISHED_ORG.GAIN_THRESHOLD}`,
);
await expect(stats.monthRow(lastMonth)).toHaveAttribute(
"aria-valuenow",
String(PLAYER_COUNT),
);
});
test("allows member of established org to create tournament", async ({
page,
factories,

View File

@@ -0,0 +1,146 @@
import type { Locator, Page } from "@playwright/test";
import { ADMIN_PAGE } from "~/utils/urls";
import {
fillDateTimeField,
navigate,
selectUser,
submit,
} from "../../helpers/playwright";
type PatronTierLabel = "Support" | "Supporter" | "Supporter+";
/** The staff action forms of the admin page, ban & unban excluded (see AdminBanPage). */
export class AdminActionsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: ADMIN_PAGE });
}
async updateFriendCode(userName: string, friendCode: string) {
const form = this.form("Update friend code");
await this.selectFormUser(form, userName);
await form.getByLabel("Friend code").fill(friendCode);
await this.submitForm(form, "Submit");
}
async linkPlayer(userName: string, playerId: number) {
const form = this.form("Link player");
await this.selectFormUser(form, userName);
await form.getByLabel("Player ID").fill(String(playerId));
await this.submitForm(form, "Link player");
}
async giveArtist(userName: string) {
const form = this.form("Add as artist");
await this.selectFormUser(form, userName);
await this.submitForm(form, "Add as artist");
}
async giveVideoAdder(userName: string) {
const form = this.form("Give video adder");
await this.selectFormUser(form, userName);
await this.submitForm(form, "Add as video adder");
}
async giveTournamentOrganizer(userName: string) {
const form = this.form("Give tournament organizer");
await this.selectFormUser(form, userName);
await this.submitForm(form, "Add as tournament organizer");
}
async giveApiAccess(userName: string) {
const form = this.form("Give API access");
await this.selectFormUser(form, userName);
await this.submitForm(form, "Grant API access");
}
async forcePatron(
userName: string,
{ tier, expiresAt }: { tier: PatronTierLabel; expiresAt: Date },
) {
const form = this.form("Force patron");
await this.selectFormUser(form, userName);
await form.getByLabel("Patron tier").selectOption({ label: tier });
await fillDateTimeField({
scope: form,
label: "Patron until",
date: expiresAt,
});
await this.submitForm(form, "Save");
}
async migrateUser({
oldUserName,
newUserName,
}: {
oldUserName: string;
newUserName: string;
}) {
const form = this.form("Migrate user data");
await selectUser({
page: this.page,
userName: oldUserName,
labelName: "Old user",
within: form,
});
await selectUser({
page: this.page,
userName: newUserName,
labelName: "New user",
within: form,
});
await this.submitForm(form, "Migrate");
}
async refreshPlusTiers() {
await this.submitForm(this.form("Refresh Plus Tiers"), "Refresh");
}
/** Types into a user search and returns the top suggestion, for asserting data the search surfaces (e.g. plus tier). */
async userSearchSuggestion(userName: string) {
const form = this.form("Add as artist");
await form.getByLabel("User").click();
await this.page.getByTestId("user-search-input").fill(userName);
return this.page.getByTestId("user-search-item").first();
}
async openFriendCodeLookUp() {
await this.page.getByRole("tab", { name: "Friend code look-up" }).click();
}
async searchFriendCode(friendCode: string) {
await this.page
.getByRole("textbox", { name: "Friend code" })
.fill(friendCode);
// scoped by test id: a role query for "Search" would also hit the header's global search
await this.page.getByTestId("submit-button").click();
}
foundUserLink(userName: string) {
return this.page.getByRole("link", { name: userName });
}
private form(title: string) {
return this.page.locator("form").filter({
has: this.page.locator("h2", { hasText: new RegExp(`^${title}$`) }),
});
}
private selectFormUser(form: Locator, userName: string) {
return selectUser({
page: this.page,
userName,
labelName: "User",
within: form,
});
}
private async submitForm(form: Locator, buttonText: string) {
await submit(this.page, form.getByRole("button", { name: buttonText }));
}
}

View File

@@ -0,0 +1,72 @@
import type { Page } from "@playwright/test";
import {
expect,
fillDateTimeField,
modalClickConfirmButton,
navigate,
submit,
} from "../../helpers/playwright";
/** `/admin/streams`, where external (non-Twitch) streams are managed. */
export class AdminStreamsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
addStreamHeading: page.getByRole("heading", {
name: "Add external stream",
}),
noStreams: page.getByText("No external streams"),
};
}
async goto() {
await navigate({ page: this.page, url: "/admin/streams" });
}
streamLink(name: string) {
return this.page.getByRole("link", { name });
}
async createStream({
name,
url,
startTime,
logoPath,
}: {
name: string;
url: string;
startTime: Date;
logoPath: string;
}) {
const form = this.form();
await form.getByLabel("Name").fill(name);
await form.getByLabel("Link").fill(url);
await form.getByLabel("Logo").setInputFiles(logoPath);
// the logo compresses in the browser; submitting before the preview shows loses it
await expect(form.locator("img")).toBeVisible();
await fillDateTimeField({
scope: form,
label: "Start time",
date: startTime,
});
await submit(this.page, form.getByRole("button", { name: "Submit" }));
}
async deleteStream(name: string) {
await this.page
.getByRole("listitem")
.filter({ hasText: name })
.getByRole("button", { name: "Delete" })
.click();
await modalClickConfirmButton(this.page);
}
private form() {
return this.page.locator("form").filter({
has: this.page.locator("h2", { hasText: /^Add external stream$/ }),
});
}
}

View File

@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { newArtPage, userArtPage } from "~/features/art/art-urls";
import { navigate } from "../../helpers/playwright";
import { modalClickConfirmButton, navigate } from "../../helpers/playwright";
/** `/u/:id/art` */
export class UserArtPage {
@@ -13,6 +13,8 @@ export class UserArtPage {
this.locators = {
images: this.page.getByTestId("art-image"),
pendingApprovalText: this.page.getByText(/pending moderator approval/i),
deleteButton: this.page.getByTestId("delete-art-button"),
unlinkButton: this.page.getByTestId("unlink-art-button"),
};
}
@@ -27,4 +29,16 @@ export class UserArtPage {
editLink(artId: Tables["Art"]["id"]) {
return this.page.locator(`a[href="${newArtPage(artId)}"]`);
}
/** Deletes the page owner's own art, only their art having a delete button. */
async deleteArt() {
await this.locators.deleteButton.click();
await modalClickConfirmButton(this.page);
}
/** Removes the page owner from art made of them, only it having an unlink button. */
async unlinkFromArt() {
await this.locators.unlinkButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,31 @@
import type { Page } from "@playwright/test";
import { articlePage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/a/:slug` */
export class ArticlePage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(slug: string) {
await navigate({ page: this.page, url: articlePage(slug) });
}
heading(title: string) {
// the markdown body repeats the title as its own h1, the page h1 comes first
return this.page.getByRole("heading", { level: 1, name: title }).first();
}
authorLink(name: string) {
return this.page
.getByRole("link", { name })
.and(this.page.locator('[href^="/u/"]'));
}
text(content: string) {
return this.page.getByText(content);
}
}

View File

@@ -0,0 +1,26 @@
import type { Page } from "@playwright/test";
import { ARTICLES_MAIN_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { ArticlePage } from "./article-page";
/** `/a` */
export class ArticlesPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: ARTICLES_MAIN_PAGE });
}
articleLink(title: string) {
return this.page.getByRole("link", { name: title });
}
async openArticle(title: string) {
await this.articleLink(title).click();
return new ArticlePage(this.page);
}
}

View File

@@ -0,0 +1,47 @@
import { type BrowserContext, expect } from "@playwright/test";
const AUTHORIZE_PATHNAME = "/oauth2/authorize";
/**
* Captures the Discord authorize URL from the login POST's redirect and serves a
* stub page in its place, so the start of the login flow can be asserted without
* discord.com being reachable. Redirects are followed inside a single request, so
* routing discord.com directly would never fire; the capture happens on `/auth`.
*/
export class DiscordAuthorizeInterceptor {
private capturedUrl: URL | null = null;
async install(context: BrowserContext) {
await context.route("**/auth", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
const response = await route.fetch({ maxRedirects: 0 });
const location = response.headers().location;
if (!location?.includes(AUTHORIZE_PATHNAME)) {
return route.fulfill({ response });
}
this.capturedUrl = new URL(location);
return route.fulfill({
contentType: "text/html",
body: "<h1>Discord authorize stub</h1>",
});
});
await context.route(/^https:\/\/discord\.com\//, (route) => route.abort());
}
async waitForCapture() {
await expect.poll(() => this.capturedUrl).not.toBeNull();
}
get authorizeUrl() {
if (!this.capturedUrl) {
throw new Error("Discord's authorize endpoint was never requested");
}
return this.capturedUrl;
}
param(name: string) {
return this.authorizeUrl.searchParams.get(name);
}
}

View File

@@ -0,0 +1,21 @@
import type { Page } from "@playwright/test";
import { navigate } from "../../helpers/playwright";
/** `/auth/login?code=...`, the single use log in links the Lohi bot hands out. */
export class LogInLinkPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(code: string) {
await navigate({ page: this.page, url: `/auth/login?code=${code}` });
}
/** An invalid link is served as a plain-text error response, not an app page. */
async fetchResponse(code: string) {
const response = await this.page.request.get(`/auth/login?code=${code}`);
return { status: response.status(), body: await response.text() };
}
}

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from "@playwright/test";
import { ADMIN_PAGE } from "~/utils/urls";
import {
fillDateTimeField,
navigate,
selectUser,
waitForPOSTResponse,
@@ -64,23 +65,11 @@ export class AdminBanPage {
}
private async fillExpiresAt(expiresAt: Date) {
const fillSegment = (segment: string, value: string) =>
this.locators.banForm
.getByRole("spinbutton", {
name: new RegExp(`^${segment}, Ban expiration date`),
})
.fill(value);
const hours = expiresAt.getHours();
await fillSegment("year", String(expiresAt.getFullYear()));
await fillSegment("month", String(expiresAt.getMonth() + 1));
await fillSegment("day", String(expiresAt.getDate()));
await fillSegment("hour", String(hours % 12 || 12));
await fillSegment(
"minute",
String(expiresAt.getMinutes()).padStart(2, "0"),
);
await fillSegment("AM/PM", hours >= 12 ? "PM" : "AM");
await fillDateTimeField({
scope: this.locators.banForm,
label: "Ban expiration date",
date: expiresAt,
});
}
private async save(form: Locator) {

View File

@@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
import { weaponBuildStatsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class BuildStatsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(weaponSlug: string) {
await navigate({ page: this.page, url: weaponBuildStatsPage(weaponSlug) });
}
buildsCountTitle(count: number, weaponName: string) {
return this.page.getByText(`Stats from ${count} ${weaponName} builds`);
}
apAverage(ap: number) {
return this.page.getByText(`${ap} AP`, { exact: true });
}
abilityPercentage(percentage: number) {
return this.page.getByText(`${percentage}%`, { exact: true });
}
}

View File

@@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
import type { Ability } from "~/modules/in-game-lists/types";
import { weaponBuildPopularPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class PopularBuildsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(weaponSlug: string) {
await navigate({
page: this.page,
url: weaponBuildPopularPage(weaponSlug),
});
}
placement(nth: number) {
return this.page.getByText(`#${nth}`, { exact: true });
}
buildCount(count: number) {
return this.page.getByText(`×${count}`, { exact: true });
}
ability(ability: Ability) {
return this.page.getByTestId(`${ability}-ability`);
}
abilityPoints(ap: number) {
return this.page.getByText(`${ap}AP`, { exact: true });
}
}

View File

@@ -1,7 +1,15 @@
import type { Page } from "@playwright/test";
import {
type BuildSort,
DEFAULT_BUILD_SORT,
} from "~/features/user-page/user-page-constants";
import invariant from "~/utils/invariant";
import { userBuildsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import {
modalClickConfirmButton,
navigate,
submit,
} from "../../helpers/playwright";
import { BuildCard } from "./build-card";
import { BuildFormPage } from "./build-form-page";
@@ -16,6 +24,7 @@ export class UserBuildsPage {
changeSortingButton: page.getByTestId("change-sorting-button"),
buildCards: page.getByTestId("build-card"),
editBuildLinks: page.getByTestId("edit-build"),
deleteBuildButtons: page.getByTestId("delete-build"),
};
}
@@ -41,4 +50,22 @@ export class UserBuildsPage {
await this.locators.editBuildLinks.nth(nth).click();
return new BuildFormPage(this.page);
}
/** Replaces the default sorts with the single given sort via the sorting dialog. */
async changeSortingTo(sort: BuildSort) {
await this.locators.changeSortingButton.click();
const dialog = this.page.getByRole("dialog");
for (let i = 0; i < DEFAULT_BUILD_SORT.length; i++) {
await dialog.getByTestId("delete-sorting-button").click();
}
await dialog.getByRole("combobox").selectOption(sort);
await submit(this.page);
}
async deleteBuild(nth: number) {
await this.locators.deleteBuildButtons.nth(nth).click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -12,6 +12,8 @@ export class WeaponBuildsPage {
this.page = page;
this.locators = {
buildCards: page.getByTestId("build-card"),
abilityStatsLink: page.getByRole("link", { name: /Ability stats/ }),
popularBuildsLink: page.getByRole("link", { name: /Popular builds/ }),
addFilterButton: page.getByTestId("add-filter-button"),
comparisonSelect: page.getByTestId("comparison-select"),
dateSelect: page.getByTestId("date-select"),

View File

@@ -1,7 +1,8 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { calendarEventPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { modalClickConfirmButton, navigate } from "../../helpers/playwright";
import { CalendarNewEventPage } from "./calendar-new-event-page";
/** `/calendar/:id` */
export class CalendarEventPage {
@@ -12,6 +13,8 @@ export class CalendarEventPage {
this.page = page;
this.locators = {
resultRows: page.getByRole("row"),
editButton: page.getByRole("link", { name: "Edit" }),
deleteButton: page.getByRole("button", { name: "Delete event" }),
};
}
@@ -22,4 +25,20 @@ export class CalendarEventPage {
resultRow(teamName: string) {
return this.locators.resultRows.filter({ hasText: teamName });
}
/** Matched on the machine-readable ISO attribute, making it timezone-agnostic. */
startTime(date: Date) {
return this.page.locator(`time[datetime="${date.toISOString()}"]`);
}
async openEdit() {
await this.locators.editButton.click();
return new CalendarNewEventPage(this.page);
}
/** Lands on the calendar page. */
async delete() {
await this.locators.deleteButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -1,5 +1,9 @@
import type { Page } from "@playwright/test";
import { calendarPage } from "~/features/calendar/calendar-urls";
import {
calendarIcalFeed,
calendarPage,
} from "~/features/calendar/calendar-urls";
import type { DayMonthYear } from "~/utils/schema";
import {
expectIsHydrated,
navigate,
@@ -27,8 +31,9 @@ export class CalendarPage {
};
}
async goto() {
await navigate({ page: this.page, url: calendarPage() });
/** Given a date, opens the calendar at that week instead of the current one. */
async goto(dayMonthYear?: DayMonthYear) {
await navigate({ page: this.page, url: calendarPage({ dayMonthYear }) });
}
tournamentCard(name: string) {
@@ -91,6 +96,13 @@ export class CalendarPage {
await this.locators.hiddenEventsButtons.first().click();
}
/** Fetches the iCal feed directly, the way a subscribed calendar app does. */
async fetchICalFeed() {
const url = new URL(calendarIcalFeed());
const response = await this.page.request.get(url.pathname + url.search);
return { status: response.status(), body: await response.text() };
}
async navigatePrevious() {
await this.locators.navigateButtons.first().click();
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
import { CONTRIBUTIONS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/contributions` */
export class ContributionsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: CONTRIBUTIONS_PAGE });
}
contributor(name: string) {
return this.page.getByText(name);
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
import { FAQ_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/faq` */
export class FaqPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: FAQ_PAGE });
}
question(text: string) {
return this.page.getByText(text);
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
import { LINKS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/links` */
export class LinksPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: LINKS_PAGE });
}
resourceLink(title: string) {
return this.page.getByRole("link", { name: title });
}
}

View File

@@ -0,0 +1,24 @@
import type { Page } from "@playwright/test";
import { SUPPORT_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/support` */
export class SupportPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
patreonLink: page.getByRole("link", { name: "Support on Patreon" }),
};
}
async goto() {
await navigate({ page: this.page, url: SUPPORT_PAGE });
}
perk(name: string) {
return this.page.getByText(name, { exact: true });
}
}

View File

@@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
import { navigate } from "../../helpers/playwright";
/** The root error boundary (`Catcher`), rendered in place of a page. */
export class ErrorPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
root: page.getByTestId("error-page"),
notFoundHeading: page.getByRole("heading", {
name: "Error 404 - Page not found",
}),
};
}
async goto(url: string) {
await navigate({ page: this.page, url });
}
async responseStatus(url: string) {
const response = await this.page.request.get(url);
return response.status();
}
heading(name: string | RegExp) {
return this.locators.root.getByRole("heading", { name });
}
text(text: string | RegExp) {
return this.locators.root.getByText(text);
}
}

View File

@@ -32,6 +32,9 @@ export class MobileNav {
streamsHeading: page.locator("h3").filter({ hasText: "Streams" }),
viewAllLink: page.getByRole("link", { name: "View all", exact: true }),
youPanelUsername: page.locator("[class*='youPanelUsername']"),
youPanelSettingsLink: this.openPanelDialog.getByRole("link", {
name: "Settings",
}),
friendItems: this.openPanelDialog.locator("button[class*='listButton']"),
};
}

View File

@@ -19,6 +19,10 @@ export class SideNav {
this.page = page;
this.root = page.locator("nav[class*='sideNav']:visible");
this.locators = {
logInButton: this.root.getByRole("button", {
name: "Log in via Discord",
}),
footerUsername: this.root.locator("[class*='sideNavFooterUsername']"),
collapseButton: page.getByTestId("sidenav-collapse-button"),
modalTrigger: page.getByTestId("sidenav-modal-trigger"),
unseenRequestsBadge: page.getByRole("status", {

View File

@@ -0,0 +1,15 @@
import type { Page } from "@playwright/test";
/** The button cluster at the right end of the site header. */
export class TopRightButtons {
readonly locators;
constructor(page: Page) {
this.locators = {
// hidden for users with any patron tier
supportLink: page
.getByRole("banner")
.getByRole("link", { name: "Support" }),
};
}
}

View File

@@ -0,0 +1,53 @@
import type { Page } from "@playwright/test";
import { leaderboardsPage } from "~/features/leaderboards/leaderboards-urls";
import { navigate, selectWeapon } from "../../helpers/playwright";
export class LeaderboardsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
noPlayersText: page.getByText("No players on the leaderboard yet"),
noTeamsText: page.getByText("No teams on the leaderboard yet"),
updateInfoText: page.getByText(
"Leaderboard is updated once every 30 minutes",
),
seasonSelect: page.getByRole("button", { name: /^Season \d+ Season$/ }),
};
}
async goto(args: { season?: number; type?: "USER" | "TEAM" } = {}) {
await navigate({ page: this.page, url: leaderboardsPage(args) });
}
tab(name: "Players" | "Teams" | "X Battle") {
return this.page.getByRole("tab", { name });
}
/** Clickable label of a filter chip, e.g. a weapon category, team scope or mode. */
filterChip(label: string) {
return this.page.getByRole("radiogroup").getByText(label, { exact: true });
}
filterChipRadio(label: string) {
return this.page.getByRole("radio", { name: label });
}
/** Name as rendered in a leaderboard entry: a player or X Battle placement row, or a team roster member. */
entryName(name: string) {
return this.page.getByText(name, { exact: true });
}
async selectSeason(season: number) {
await this.locators.seasonSelect.click();
await this.page
.getByRole("option", { name: `Season ${season}`, exact: true })
.click();
}
async selectXPWeapon(name: string) {
await selectWeapon({ page: this.page, name });
}
}

View File

@@ -0,0 +1,55 @@
import type { Page } from "@playwright/test";
import { MAPS_URL } from "~/utils/urls";
import { expect, expectIsHydrated, navigate } from "../../helpers/playwright";
export class MapListGeneratorPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
clearButton: page.getByRole("button", { name: "Clear" }),
createMapListButton: page.getByRole("button", {
name: "Create map list",
}),
generatedMapListItems: page
.locator("ol[class*='mapList']")
.getByRole("listitem"),
};
}
async goto() {
await navigate({ page: this.page, url: MAPS_URL });
}
/** Reloads the page after asserting the pool was serialized to the URL. */
async reloadWithPersistedPool() {
await expect(this.page).toHaveURL(/pool=/);
await this.page.reload();
await expectIsHydrated(this.page);
}
stageRow(stageName: string) {
return this.page.getByRole("group", { name: stageName });
}
modeButton(stageName: string, modeName: string) {
return this.stageRow(stageName).getByRole("button", {
name: modeName,
exact: true,
});
}
async toggleMode(stageName: string, modeName: string) {
await this.modeButton(stageName, modeName).click();
}
async clearMapPool() {
await this.locators.clearButton.click();
}
async createMapList() {
await this.locators.createMapListButton.click();
}
}

View File

@@ -7,10 +7,15 @@ import { createFormHelpers } from "../../helpers/playwright-form";
export class NewOrganizationPage {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, newOrganizationSchema);
this.locators = {
heading: page.getByRole("heading", { name: "New Organization" }),
noPermissionsAlert: page.getByText("No permissions to add organizations"),
};
}
async goto() {

View File

@@ -13,6 +13,7 @@ import {
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { OrganizationEditPage } from "./organization-edit-page";
import { OrganizationStatsPage } from "./organization-stats-page";
export class OrganizationPage {
private readonly page: Page;
@@ -24,6 +25,7 @@ export class OrganizationPage {
this.isEstablishedForm = createFormHelpers(page, updateIsEstablishedSchema);
this.locators = {
editButton: page.getByTestId("edit-org-button"),
statsButton: page.getByTestId("org-stats-button"),
bannedUsersTab: page.getByTestId("banned-users-tab"),
adminTab: page.getByRole("tab", { name: "Admin" }),
newBanButton: page.getByRole("button", { name: "New ban" }),
@@ -44,6 +46,11 @@ export class OrganizationPage {
return new OrganizationEditPage(this.page);
}
async openStats() {
await this.locators.statsButton.click();
return new OrganizationStatsPage(this.page);
}
/** Established organizations can add tournaments and their admins can edit them. */
async establish() {
await this.locators.adminTab.click();

View File

@@ -0,0 +1,23 @@
import type { Page } from "@playwright/test";
import { format } from "date-fns";
export class OrganizationStatsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
establishedStatus: page.getByRole("progressbar", {
name: "Established status",
}),
};
}
/** The month's row of the participant breakdown, e.g. "Jul 2026". */
monthRow(month: Date) {
return this.page.getByRole("progressbar", {
name: format(month, "MMM yyyy"),
});
}
}

View File

@@ -0,0 +1,61 @@
import type { Page } from "@playwright/test";
import { PLANNER_URL } from "~/utils/urls";
import { expect, navigate } from "../../helpers/playwright";
export class MapPlannerPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
canvas: page.locator(".tl-canvas"),
imageShapes: page.locator(".tl-shape[data-shape-type='image']"),
stageSelect: page.getByLabel("Select stage"),
setBackgroundButton: page.getByRole("button", {
name: "Set background",
}),
};
}
async goto() {
await navigate({ page: this.page, url: PLANNER_URL });
await expect(this.locators.canvas).toBeVisible();
}
async setBackground(stageName: string) {
await this.locators.stageSelect.selectOption({ label: stageName });
await this.locators.setBackgroundButton.click();
}
async openWeaponCategory(categoryName: string) {
await this.page.getByText(categoryName, { exact: true }).click();
}
/** Drags via raw mouse events because the weapon buttons are dnd-kit draggables, not native HTML drag sources. */
async dragWeaponToCanvas(weaponName: string) {
const weaponButton = this.page.getByRole("button", {
name: weaponName,
exact: true,
});
await weaponButton.scrollIntoViewIfNeeded();
const sourceBox = await weaponButton.boundingBox();
const canvasBox = await this.locators.canvas.boundingBox();
if (!sourceBox || !canvasBox) {
throw new Error("Missing bounding box for drag");
}
await this.page.mouse.move(
sourceBox.x + sourceBox.width / 2,
sourceBox.y + sourceBox.height / 2,
);
await this.page.mouse.down();
await this.page.mouse.move(
canvasBox.x + canvasBox.width * 0.6,
canvasBox.y + canvasBox.height * 0.6,
{ steps: 10 },
);
await this.page.mouse.up();
}
}

View File

@@ -0,0 +1,30 @@
import type { Page } from "@playwright/test";
import { newSuggestionFormSchema } from "~/features/plus-suggestions/plus-suggestions-schemas";
import { plusSuggestionsNewPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** The "Adding a new suggestion" dialog, a child route of the suggestions page. */
export class NewSuggestionPage {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, newSuggestionFormSchema);
this.locators = {
heading: page.getByRole("heading", { name: "Adding a new suggestion" }),
};
}
async goto() {
await navigate({ page: this.page, url: plusSuggestionsNewPage() });
}
async suggest({ username, comment }: { username: string; comment: string }) {
await this.form.selectUser("userId", username);
await this.form.fill("comment", comment);
await submit(this.page);
}
}

View File

@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
import { followUpCommentFormSchema } from "~/features/plus-suggestions/plus-suggestions-schemas";
import { plusSuggestionCommentPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** The follow-up comment dialog, a child route of the suggestions page. */
export class SuggestionCommentPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, followUpCommentFormSchema);
}
async goto({ tier, userId }: { tier: number; userId: number }) {
await navigate({
page: this.page,
url: plusSuggestionCommentPage({ tier, userId }),
});
}
heading({ username, tier }: { username: string; tier: number }) {
return this.page.getByRole("heading", {
name: `${username}'s +${tier} suggestion`,
});
}
async comment(text: string) {
await this.form.fill("comment", text);
await submit(this.page);
}
}

View File

@@ -0,0 +1,73 @@
import type { Page } from "@playwright/test";
import { editSuggestionFormSchema } from "~/features/plus-suggestions/plus-suggestions-schemas";
import { plusSuggestionPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import {
modalClickConfirmButton,
navigate,
submit,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class PlusSuggestionsPage {
private readonly page: Page;
readonly editForm;
readonly locators;
constructor(page: Page) {
this.page = page;
this.editForm = createFormHelpers(page, editSuggestionFormSchema);
this.locators = {
noSuggestions: page.getByText("No suggestions yet"),
noPermissionsAlert: page.getByText(
"You do not have permissions to suggest or suggesting is not possible right now",
),
commentLink: page.getByRole("link", { name: "Comment", exact: true }),
/** Deletes the user's own suggestion of themselves, one button per tier. */
deleteOwnSuggestionButton: page.getByRole("button", {
name: "Delete",
exact: true,
}),
};
}
async goto(tier?: number) {
await navigate({ page: this.page, url: plusSuggestionPage({ tier }) });
}
suggestedUser(username: string) {
return this.page.getByRole("heading", { name: username });
}
commentsSummary(count: number) {
return this.page.getByText(`Comments (${count})`);
}
async openComments(count: number) {
const details = this.page
.locator("details")
.filter({ has: this.commentsSummary(count) });
if ((await details.getAttribute("open")) === null) {
await this.commentsSummary(count).click();
}
}
comment(text: string) {
return this.page.locator("fieldset").filter({ hasText: text });
}
async editComment(currentText: string, newText: string) {
await this.comment(currentText).getByLabel("Edit").click();
await this.editForm.fill("comment", newText);
await submit(this.page);
}
async deleteComment(text: string) {
await this.comment(text).getByLabel("Delete comment").click();
await modalClickConfirmButton(this.page);
}
async deleteOwnSuggestion() {
await this.locators.deleteOwnSuggestionButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,31 @@
import type { Page } from "@playwright/test";
import { PLUS_VOTING_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
export class PlusVotingPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
upvoteButton: page.getByRole("button", { name: "+1", exact: true }),
submitVotesButton: page.getByRole("button", { name: "Submit votes" }),
votedAlert: page.getByText("You have voted"),
votingStartsInfo: page.getByText("Next voting starts"),
votingOngoingInfo: page.getByText("Voting is currently happening"),
};
}
async goto() {
await navigate({ page: this.page, url: PLUS_VOTING_PAGE });
}
async upvoteCurrent() {
await this.locators.upvoteButton.click();
}
async submitVotes() {
await submit(this.page, this.locators.submitVotesButton);
}
}

View File

@@ -0,0 +1,46 @@
import type { Page } from "@playwright/test";
import { PLUS_VOTING_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class PlusVotingResultsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
heading: page.getByRole("heading", { name: /Voting results for/ }),
};
}
async goto() {
await navigate({ page: this.page, url: `${PLUS_VOTING_PAGE}/results` });
}
passedHeading(count: number) {
return this.page.getByRole("heading", { name: `Passed (${count})` });
}
failedHeading(count: number) {
return this.page.getByRole("heading", { name: `Didn't pass (${count})` });
}
userResult(username: string) {
return this.page
.getByRole("main")
.getByRole("link", { name: username })
.first();
}
/** The "S" marker rendered on results of users who were in the voting via a suggestion. */
suggestedMarker(username: string) {
return this.userResult(username).getByText("S", { exact: true });
}
/** The logged-in user's own "You passed/didn't pass the +X voting" line. */
ownResult({ tier, passed }: { tier: number; passed: boolean }) {
return this.page.locator("li").filter({
hasText: `You ${passed ? "passed" : "didn't pass"} the +${tier} voting`,
});
}
}

View File

@@ -0,0 +1,16 @@
import type { Page } from "@playwright/test";
import { SCANNER_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/scanner` */
export class ScannerPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: SCANNER_PAGE });
}
}

View File

@@ -0,0 +1,19 @@
import type { Page } from "@playwright/test";
import { SENDOUQ_INFO_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class QInfoPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
generalInfoHeading: page.getByRole("heading", { name: "General info" }),
};
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_INFO_PAGE });
}
}

View File

@@ -0,0 +1,19 @@
import type { Page } from "@playwright/test";
import { SENDOUQ_RULES_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class QRulesPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
heading: page.getByRole("heading", { name: "SendouQ Rules" }),
};
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_RULES_PAGE });
}
}

View File

@@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
import { SENDOUQ_STREAMS_PAGE, twitchUrl } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class QStreamsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
noStreamsText: page.getByText("No streamed matches currently"),
};
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_STREAMS_PAGE });
}
streamerLink(username: string) {
return this.page.getByRole("link", { name: username });
}
matchLink(matchId: number) {
return this.page.getByRole("link", { name: `#${matchId}`, exact: true });
}
twitchLink(accountName: string) {
return this.page.locator(`a[href="${twitchUrl(accountName)}"]`);
}
viewerCount(count: number) {
return this.page.getByText(String(count), { exact: true });
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
import type { TierName } from "~/features/mmr/mmr-constants";
import { TIERS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class TiersPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: TIERS_PAGE });
}
tierImage(tierName: TierName) {
return this.page.getByRole("img", { name: tierName, exact: true });
}
}

View File

@@ -12,7 +12,7 @@ import {
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
type SettingsTab = "preferences" | "locale" | "theme";
type SettingsTab = "preferences" | "locale" | "theme" | "sounds";
/** `/settings` preference toggles and the theme tab; the match profile tab has its own page object. */
export class SettingsPage {
@@ -31,18 +31,50 @@ export class SettingsPage {
this.clockFormatForm = createFormHelpers(page, clockFormatSchema);
this.spoilerFreeModeForm = createFormHelpers(page, spoilerFreeModeSchema);
this.locators = {
buildAbilitySortingToggle: page.getByLabel(
this.buildAbilitySortingForm.getLabel("newValue"),
),
baseHueSlider: page.locator("#base-hue"),
saveThemeButton: page.getByRole("button", { name: "Save" }).first(),
resetThemeButton: page.getByRole("button", { name: "Reset" }).first(),
// matches both the English and the Japanese label so the language test
// can switch back after leaving the UI in Japanese
pageHeading: page.getByRole("heading", { name: /^(Settings|設定)$/ }),
languageSelect: page.getByLabel(/^(Language|言語) *$/),
themeSelect: page.getByRole("combobox", { name: "Theme" }),
htmlRoot: page.locator("html"),
volumeSlider: page.getByRole("slider"),
logOutButton: page.getByRole("button", { name: "Log out" }),
};
}
async goto(tab: SettingsTab) {
await navigate({ page: this.page, url: `${SETTINGS_PAGE}?tab=${tab}` });
async goto(tab?: SettingsTab) {
await navigate({
page: this.page,
url: tab ? `${SETTINGS_PAGE}?tab=${tab}` : SETTINGS_PAGE,
});
}
/** Submits the log out form; a native form POST followed by a redirect to the front page. */
async logOut() {
await this.locators.logOutButton.click();
await this.page.waitForURL("/");
await expectIsHydrated(this.page);
}
async selectTab(
name: "Match profile" | "Preferences" | "Locale" | "Theme" | "Sounds",
) {
await this.page.getByRole("tab", { name }).click();
}
async disableBuildAbilitySorting() {
await this.goto("preferences");
await this.checkDisableBuildAbilitySortingToggle();
}
/** Checks the toggle on an already open preferences tab. */
async checkDisableBuildAbilitySortingToggle() {
await waitForPOSTResponse(this.page, () =>
this.buildAbilitySortingForm.check("newValue"),
);
@@ -62,6 +94,26 @@ export class SettingsPage {
);
}
/** Selects an interface language on the locale tab by its native name. */
async selectLanguage(name: "English" | "日本語") {
await this.locators.languageSelect.selectOption({ label: name });
}
/** Selects dark/light/auto on the theme tab; persisted via a POST to /theme. */
async setTheme(theme: "Auto" | "Dark" | "Light") {
await waitForPOSTResponse(this.page, async () => {
await this.locators.themeSelect.selectOption({ label: theme });
});
}
soundCheckbox(name: string) {
return this.page.getByRole("checkbox", { name });
}
async setSoundVolume(value: string) {
await this.locators.volumeSlider.fill(value);
}
async setBaseHue(value: string) {
await this.locators.baseHueSlider.fill(value);
}

View File

@@ -6,6 +6,7 @@ import {
waitForPOSTResponse,
} from "../../helpers/playwright";
import { TeamEditPage } from "./team-edit-page";
import { TeamResultsPage } from "./team-results-page";
import { TeamRosterPage } from "./team-roster-page";
export class TeamPage {
@@ -26,6 +27,7 @@ export class TeamPage {
deleteTeamButton: page.getByTestId("delete-team-button"),
otherRolesTab: page.getByRole("tab", { name: /Other/ }),
confirmDialog: page.getByRole("dialog"),
resultsBannerLink: page.getByRole("link", { name: /View \d+ results/ }),
};
}
@@ -55,6 +57,11 @@ export class TeamPage {
return new TeamEditPage(this.page);
}
async openResults() {
await this.locators.resultsBannerLink.click();
return new TeamResultsPage(this.page);
}
async openActionsMenu() {
await this.locators.actionsMenuButton.click();
}

View File

@@ -0,0 +1,26 @@
import type { Page } from "@playwright/test";
export class TeamResultsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
table: page.getByRole("table"),
};
}
resultRow(tournamentName: string) {
return this.locators.table
.getByRole("row")
.filter({ has: this.page.getByRole("link", { name: tournamentName }) });
}
/** The medal icon of the row, named by the placement ordinal e.g. "1st". */
placement(tournamentName: string, placementText: string) {
return this.resultRow(tournamentName).getByRole("img", {
name: placementText,
});
}
}

View File

@@ -103,6 +103,13 @@ export class TournamentAdminRegistrationPage {
await this.locators.importTeamButton.click();
}
/** The roster member select showing the given user, once their name has resolved. */
memberWithName(name: string) {
return this.page
.getByRole("button", { name: "User search" })
.filter({ hasText: name });
}
/** Picks the source tournament; its team `<select>` populates asynchronously
* from the import loader and auto-selects the first team. */
async importFirstTeamFrom(tournamentQuery: string) {

View File

@@ -0,0 +1,33 @@
import type { Page } from "@playwright/test";
import { tournamentDivisionsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { TournamentBracketsPage } from "./tournament-brackets-page";
/** `/to/:id/divisions` — a league's divisions, each linking to its brackets. */
export class TournamentDivisionsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
divisionLinks: page.getByTestId("division-link"),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: tournamentDivisionsPage(tournamentId),
});
}
divisionLink(name: string) {
return this.page.getByRole("link", { name });
}
async openDivision(name: string) {
await this.divisionLink(name).click();
return new TournamentBracketsPage(this.page);
}
}

View File

@@ -21,6 +21,14 @@ export class TournamentPage {
await navigate({ page: this.page, url: tournamentPage(tournamentId) });
}
heading(name: string) {
return this.page.getByRole("heading", { level: 1, name });
}
nameHeading(name: string) {
return this.page.getByRole("heading", { level: 1, name });
}
async register() {
await this.locators.registerCta.click();
return new TournamentRegisterPage(this.page);

View File

@@ -0,0 +1,37 @@
import type { Page } from "@playwright/test";
import { userPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
/** `/u/:identifier/edit-widgets` */
export class UserEditWidgetsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
saveButton: page.getByRole("button", { name: "Save", exact: true }),
};
}
async goto(discordId: string) {
await navigate({
page: this.page,
url: `${userPage({ discordId })}/edit-widgets`,
});
}
/** Adds a widget from the gallery by its id, e.g. `"bio"` or `"join-date"`. */
async addWidget(widgetId: string) {
await this.page.getByTestId(`add-widget-${widgetId}`).click();
}
/** Fills the bio widget's settings, expanded right after adding it. */
async fillBio(text: string) {
await this.page.getByLabel("Bio").fill(text);
}
async save() {
await submit(this.page, this.locators.saveButton);
}
}

View File

@@ -4,7 +4,9 @@ import { navigate } from "../../helpers/playwright";
import { TeamPage } from "../team/team-page";
import { TopSearchPlayerPage } from "../top-search/top-search-player-page";
import { UserEditProfilePage } from "./user-edit-profile-page";
import { UserEditWidgetsPage } from "./user-edit-widgets-page";
import { UserResultsPage } from "./user-results-page";
import { UserVodsPage } from "./user-vods-page";
export class UserPage {
private readonly page: Page;
@@ -19,7 +21,10 @@ export class UserPage {
badgeDisplay: page.getByTestId("badge-display"),
badgePaginationButtons: page.getByTestId("badge-pagination-button"),
editProfileButton: page.getByText("Edit", { exact: true }),
editWidgetsButton: page.getByRole("link", { name: "Edit Widgets" }),
seasonsTab: page.getByTestId("user-seasons-tab"),
// the icon nav has a desktop and a mobile copy, only one of them shown
vodsTab: page.locator('[data-testid="user-vods-tab"]:visible'),
resultsTab: page.getByTestId("user-results-tab"),
seasonsTournamentResult: page.getByTestId("seasons-tournament-result"),
};
@@ -46,6 +51,19 @@ export class UserPage {
return this.page.getByText(content);
}
exactText(content: string) {
return this.page.getByText(content, { exact: true });
}
/** The title of a widget on the new (widgets-enabled) profile. */
widgetHeading(name: string) {
return this.page.getByRole("heading", { name, exact: true });
}
usernameHeading(username: string) {
return this.page.getByRole("heading", { name: username });
}
async openEditProfile() {
await this.locators.editProfileButton.click();
return new UserEditProfilePage(this.page);
@@ -70,4 +88,14 @@ export class UserPage {
await this.locators.resultsTab.click();
return new UserResultsPage(this.page);
}
async openVods() {
await this.locators.vodsTab.click();
return new UserVodsPage(this.page);
}
async openEditWidgets() {
await this.locators.editWidgetsButton.click();
return new UserEditWidgetsPage(this.page);
}
}

View File

@@ -0,0 +1,28 @@
import type { Page } from "@playwright/test";
import { userResultsEditHighlightsPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
/** `/u/:identifier/results/highlights` */
export class UserResultsHighlightsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(discordId: string) {
await navigate({
page: this.page,
url: userResultsEditHighlightsPage({ discordId }),
});
}
/** A result's highlight checkbox, labeled by the event name and placement. */
resultCheckbox(resultName: RegExp) {
return this.page.getByRole("checkbox", { name: resultName });
}
async save() {
await submit(this.page);
}
}

View File

@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { userResultsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { UserResultsHighlightsPage } from "./user-results-highlights-page";
/** `/u/:identifier/results` */
export class UserResultsPage {
@@ -12,6 +13,9 @@ export class UserResultsPage {
this.locators = {
tournamentNameCells: page.getByTestId("tournament-name-cell"),
matesButtons: page.getByTestId("mates-button"),
chooseHighlightsButton: page.getByRole("link", {
name: "Choose highlights",
}),
};
}
@@ -23,6 +27,16 @@ export class UserResultsPage {
return this.locators.tournamentNameCells.getByText(name);
}
/** A calendar event result's link to the event. */
eventName(name: string) {
return this.page.getByRole("link", { name });
}
async openChooseHighlights() {
await this.locators.chooseHighlightsButton.click();
return new UserResultsHighlightsPage(this.page);
}
async openMates(nth: number) {
await this.locators.matesButtons.nth(nth).click();
}

View File

@@ -40,4 +40,23 @@ export class UserSeasonsPage {
return downloadPromise;
}
async openStatsTab(name: "Weapons" | "Stages" | "Teammates" | "Opponents") {
await this.page.getByRole("tab", { name }).click();
}
/** A weapon of the Weapons tab, labeled with its usage share, e.g. `"Luna Blaster (100%)"`. */
weaponUsageImage(label: string) {
return this.page.getByRole("img", { name: label });
}
/** A per-mode win/loss record of the Stages tab, e.g. `"4W 0L"`. */
stageRecord(record: string) {
return this.page.getByText(record, { exact: true });
}
/** A player of the Teammates/Opponents tab, linking to their seasons page. */
playerLink(username: string) {
return this.page.getByRole("link", { name: username });
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
import { userVodsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/u/:identifier/vods` */
export class UserVodsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(discordId: string) {
await navigate({ page: this.page, url: userVodsPage({ discordId }) });
}
vodTitle(title: string) {
return this.page.getByRole("heading", { name: title });
}
}

View File

@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { vodVideoPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { modalClickConfirmButton, navigate } from "../../helpers/playwright";
import { NewVodPage } from "./new-vod-page";
/** `/vods/:id` */
@@ -16,6 +16,10 @@ export class VodPage {
copyTimestampsButton: this.page.getByTestId("copy-timestamps-button"),
timestamps: this.page.getByRole("dialog").getByRole("textbox"),
editButton: this.page.getByTestId("edit-vod-button"),
deleteButton: this.page.getByRole("button", {
name: "Delete",
exact: true,
}),
};
}
@@ -40,4 +44,10 @@ export class VodPage {
await this.locators.editButton.click();
return new NewVodPage(this.page);
}
/** Lands on the deleter's own vods page. */
async delete() {
await this.locators.deleteButton.click();
await modalClickConfirmButton(this.page);
}
}

209
e2e/plus.spec.ts Normal file
View File

@@ -0,0 +1,209 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { lastCompletedVoting } from "~/features/plus-voting/core/voting-time";
import {
PLUS_DOWNVOTE,
PLUS_UPVOTE,
} from "~/features/plus-voting/plus-voting-constants";
import { PLUS_VOTING_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
isNotVisible,
runRoutine,
setPlusVotingActive,
test,
} from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { NewSuggestionPage } from "./pages/plus/new-suggestion-page";
import { SuggestionCommentPage } from "./pages/plus/suggestion-comment-page";
import { PlusSuggestionsPage } from "./pages/plus/suggestions-page";
import { PlusVotingPage } from "./pages/plus/voting-page";
import { PlusVotingResultsPage } from "./pages/plus/voting-results-page";
const SUGGESTED_NAME = "SuggestedSue";
const SUGGESTED_DISCORD_ID = "1000000000000000001";
const FAILER_NAME = "FalterFred";
const FAILER_DISCORD_ID = "1000000000000000002";
const SUGGESTION_TEXT = "Great player and even better person";
const EDITED_SUGGESTION_TEXT = "Great player and an amazing person";
const FOLLOW_UP_COMMENT_TEXT = "Seconding, deserves the spot";
test.describe("Plus Server", () => {
test("member suggests a user, members vote and results decide the memberships", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { plusTier: 1 });
await factories.UserFactory.grant(NZAP_TEST_ID, { plusTier: 1 });
const suggested = await factories.UserFactory.create({
discordName: SUGGESTED_NAME,
discordId: SUGGESTED_DISCORD_ID,
profile: null,
});
await impersonate(page);
const suggestions = new PlusSuggestionsPage(page);
await suggestions.goto();
await expect(suggestions.locators.noSuggestions).toBeVisible();
const newSuggestion = new NewSuggestionPage(page);
await newSuggestion.goto();
await expect(newSuggestion.locators.heading).toBeVisible();
await newSuggestion.suggest({
username: SUGGESTED_NAME,
comment: SUGGESTION_TEXT,
});
await expect(suggestions.suggestedUser(SUGGESTED_NAME)).toBeVisible();
await suggestions.openComments(1);
await expect(suggestions.comment(SUGGESTION_TEXT)).toBeVisible();
await suggestions.editComment(SUGGESTION_TEXT, EDITED_SUGGESTION_TEXT);
await suggestions.openComments(1);
await expect(suggestions.comment(EDITED_SUGGESTION_TEXT)).toBeVisible();
await impersonate(page, NZAP_TEST_ID);
await suggestions.goto();
await suggestions.locators.commentLink.click();
const commentPage = new SuggestionCommentPage(page);
await expect(
commentPage.heading({ username: SUGGESTED_NAME, tier: 1 }),
).toBeVisible();
await commentPage.comment(FOLLOW_UP_COMMENT_TEXT);
await suggestions.openComments(2);
await expect(suggestions.comment(FOLLOW_UP_COMMENT_TEXT)).toBeVisible();
await suggestions.deleteComment(FOLLOW_UP_COMMENT_TEXT);
await expect(suggestions.commentsSummary(1)).toBeVisible();
await setPlusVotingActive(page, true);
await runRoutine(page, "NotifyPlusServerVoting");
await suggestions.goto();
const notifications = new NotificationPopover(page);
await notifications.open();
await notifications.openNotification("Plus Server voting of season");
await expect(page).toHaveURL(PLUS_VOTING_PAGE);
const voting = new PlusVotingPage(page);
await expect(voting.locators.upvoteButton).toBeVisible();
await voting.upvoteCurrent();
await voting.upvoteCurrent();
await voting.submitVotes();
await expect(voting.locators.votedAlert).toBeVisible();
await setPlusVotingActive(page, false);
await voting.goto();
await expect(voting.locators.votingStartsInfo).toBeVisible();
await impersonate(page, suggested.id);
await suggestions.goto();
await notifications.open();
await expect(
notifications.notification("You were suggested to +1"),
).toBeVisible();
await notifications.close();
await suggestions.deleteOwnSuggestion();
await expect(suggestions.locators.noSuggestions).toBeVisible();
const completedVoting = lastCompletedVoting(new Date());
const failer = await factories.UserFactory.create({
discordName: FAILER_NAME,
discordId: FAILER_DISCORD_ID,
profile: null,
});
await factories.PlusSuggestionFactory.create({
authorId: ADMIN_ID,
suggestedId: suggested.id,
tier: 1,
...completedVoting,
});
await factories.PlusVoteFactory.create({
authorId: ADMIN_ID,
votedId: NZAP_TEST_ID,
score: PLUS_UPVOTE,
});
await factories.PlusVoteFactory.create({
authorId: ADMIN_ID,
votedId: suggested.id,
score: PLUS_UPVOTE,
});
await factories.PlusVoteFactory.create({
authorId: ADMIN_ID,
votedId: failer.id,
score: PLUS_DOWNVOTE,
});
await factories.PlusVoteFactory.syncTiers();
const results = new PlusVotingResultsPage(page);
await results.goto();
await expect(results.locators.heading).toBeVisible();
await expect(results.ownResult({ tier: 1, passed: true })).toBeVisible();
await expect(results.passedHeading(2)).toBeVisible();
await expect(results.failedHeading(1)).toBeVisible();
await expect(results.userResult("N-ZAP")).toBeVisible();
await expect(results.suggestedMarker(SUGGESTED_NAME)).toBeVisible();
await expect(results.userResult(FAILER_NAME)).toBeVisible();
const plusListResponse = await page.request.get("/plus/list", {
headers: { "Lohi-Token": process.env.LOHI_TOKEN ?? "salmon" },
});
expect(plusListResponse.ok()).toBe(true);
const plusList = (await plusListResponse.json()) as {
users: Record<string, number>;
};
expect(plusList.users[SUGGESTED_DISCORD_ID]).toBe(1);
// a non-suggested member who fails the vote drops one tier instead of out
expect(plusList.users[FAILER_DISCORD_ID]).toBe(2);
});
test("user without a plus tier cannot suggest, comment or vote", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { plusTier: 1 });
const suggested = await factories.UserFactory.create({
discordName: SUGGESTED_NAME,
discordId: SUGGESTED_DISCORD_ID,
profile: null,
});
await factories.PlusSuggestionFactory.create({
authorId: ADMIN_ID,
suggestedId: suggested.id,
tier: 1,
text: SUGGESTION_TEXT,
});
await impersonate(page, NZAP_TEST_ID);
const suggestions = new PlusSuggestionsPage(page);
await suggestions.goto();
await expect(suggestions.suggestedUser(SUGGESTED_NAME)).toBeVisible();
await isNotVisible(suggestions.locators.commentLink);
const newSuggestion = new NewSuggestionPage(page);
await newSuggestion.goto();
await expect(suggestions.locators.noPermissionsAlert).toBeVisible();
await isNotVisible(newSuggestion.locators.heading);
const commentPage = new SuggestionCommentPage(page);
await commentPage.goto({ tier: 1, userId: suggested.id });
await expect(page).not.toHaveURL(/comment/);
await isNotVisible(
commentPage.heading({ username: SUGGESTED_NAME, tier: 1 }),
);
await setPlusVotingActive(page, true);
const voting = new PlusVotingPage(page);
await voting.goto();
await expect(voting.locators.votingOngoingInfo).toBeVisible();
await isNotVisible(voting.locators.upvoteButton);
});
});

161
e2e/public-pages.spec.ts Normal file
View File

@@ -0,0 +1,161 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { expect, expectNoErrorPage, test } from "./helpers/playwright";
import { ArticlesPage } from "./pages/articles/articles-page";
import { BuildsPage } from "./pages/builds/builds-page";
import { WeaponBuildsPage } from "./pages/builds/weapon-builds-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
import { FrontPage } from "./pages/front-page/front-page";
import { ContributionsPage } from "./pages/info/contributions-page";
import { FaqPage } from "./pages/info/faq-page";
import { LinksPage } from "./pages/info/links-page";
import { SupportPage } from "./pages/info/support-page";
import { ErrorPage } from "./pages/layout/error-page";
import { ScannerPage } from "./pages/scanner/scanner-page";
import { TournamentPage } from "./pages/tournament/tournament-page";
import { UserPage } from "./pages/user/user-page";
const PUBLIC_USER = {
discordId: "123456789012345678",
discordName: "Chirpy",
};
const BUILD_WEAPON_ID = 40;
const EVENT_NAME = "Ink Clash Open";
const TOURNAMENT_NAME = "Public Pages Cup";
const ICS_EVENT_NAME = "ICS Feed Cup";
const ARTICLE = {
slug: "results-from-riptide-2025",
title: "Results from Riptide 2025",
author: "YELLOW",
};
test.describe("Public pages", () => {
test("renders public pages for a logged-out visitor", async ({
page,
factories,
}) => {
const user = await factories.UserFactory.create({
discordId: PUBLIC_USER.discordId,
discordName: PUBLIC_USER.discordName,
});
await factories.BuildFactory.create({
ownerId: user.id,
weaponSplIds: [BUILD_WEAPON_ID],
});
const startTimes = [dateToDatabaseTimestamp(new Date())];
await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: EVENT_NAME,
startTimes,
});
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
startTimes,
});
const front = new FrontPage(page);
await front.goto();
await expectNoErrorPage(page);
await expect(front.locators.welcomeBanner).toBeVisible();
const userPage = new UserPage(page);
await userPage.goto(PUBLIC_USER.discordId);
await expectNoErrorPage(page);
await expect(
userPage.usernameHeading(PUBLIC_USER.discordName),
).toBeVisible();
const builds = new BuildsPage(page);
await builds.goto();
await expectNoErrorPage(page);
await builds.weaponLink(BUILD_WEAPON_ID).click();
const weaponBuilds = new WeaponBuildsPage(page);
await expect(weaponBuilds.locators.buildCards).toHaveCount(1);
const calendar = new CalendarPage(page);
await calendar.goto();
await expectNoErrorPage(page);
await expect(calendar.tournamentCard(EVENT_NAME)).toBeVisible();
await expect(calendar.tournamentCard(TOURNAMENT_NAME)).toBeVisible();
const tournamentPage = new TournamentPage(page);
await tournamentPage.goto(tournament.id);
await expectNoErrorPage(page);
await expect(tournamentPage.heading(TOURNAMENT_NAME)).toBeVisible();
const faq = new FaqPage(page);
await faq.goto();
await expectNoErrorPage(page);
await expect(faq.question("What is the Plus Server?")).toBeVisible();
const support = new SupportPage(page);
await support.goto();
await expectNoErrorPage(page);
await expect(support.locators.patreonLink).toBeVisible();
await expect(support.perk("Ad-free browsing")).toBeVisible();
const contributions = new ContributionsPage(page);
await contributions.goto();
await expectNoErrorPage(page);
await expect(contributions.contributor("hfcRed")).toBeVisible();
const links = new LinksPage(page);
await links.goto();
await expectNoErrorPage(page);
await expect(links.resourceLink("Inkipedia")).toBeVisible();
// scanner is not publicly enabled in the test env: the route renders and
// sends a logged-out visitor to the front page instead of erroring
const scanner = new ScannerPage(page);
await scanner.goto();
await expectNoErrorPage(page);
await expect(page).toHaveURL("/");
});
test("lists articles and renders one by slug", async ({ page }) => {
const articles = new ArticlesPage(page);
await articles.goto();
await expectNoErrorPage(page);
const article = await articles.openArticle(ARTICLE.title);
await expect(article.heading(ARTICLE.title)).toBeVisible();
await expect(article.authorLink(ARTICLE.author)).toBeVisible();
await article.goto(ARTICLE.slug);
await expect(article.heading(ARTICLE.title)).toBeVisible();
await expect(
article.text("largest North American Splatoon LAN in history"),
).toBeVisible();
});
test("redirects moved URLs, renders 404 for unknown ones and serves the calendar feed", async ({
page,
factories,
}) => {
await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: ICS_EVENT_NAME,
startTimes: [dateToDatabaseTimestamp(new Date())],
});
const errorPage = new ErrorPage(page);
await errorPage.goto("/u");
await expect(page).toHaveURL("/?search=open&type=users");
await expect(errorPage.locators.root).toHaveCount(0);
await errorPage.goto("/this-page-does-not-exist");
await expect(errorPage.locators.root).toBeVisible();
await expect(errorPage.locators.notFoundHeading).toBeVisible();
expect(await errorPage.responseStatus("/this-page-does-not-exist")).toBe(
404,
);
const calendar = new CalendarPage(page);
const feed = await calendar.fetchICalFeed();
expect(feed.status).toBe(200);
expect(feed.body).toContain("BEGIN:VCALENDAR");
expect(feed.body).toContain(ICS_EVENT_NAME);
});
});

65
e2e/q-pages.spec.ts Normal file
View File

@@ -0,0 +1,65 @@
import { sendouQMatchPage } from "~/utils/urls";
import { expect, expectNoErrorPage, test } from "./helpers/playwright";
import { createNamedUsers, createUserIds } from "./helpers/sidebar";
import { QInfoPage } from "./pages/sendouq/q-info-page";
import { QRulesPage } from "./pages/sendouq/q-rules-page";
import { QStreamsPage } from "./pages/sendouq/q-streams-page";
import { TiersPage } from "./pages/sendouq/tiers-page";
const STREAMER_TWITCH = "q_streamer";
const STREAM_VIEWER_COUNT = 777;
test.describe("SendouQ pages", () => {
test("streams page shows the empty state, then a seeded live match", async ({
page,
factories,
}) => {
const streamsPage = new QStreamsPage(page);
await streamsPage.goto();
await expectNoErrorPage(page);
await expect(streamsPage.locators.noStreamsText).toBeVisible();
const [streamer] = await createNamedUsers(factories, ["QStreamer"], {
twitch: STREAMER_TWITCH,
});
const match = await factories.SQMatchFactory.create({
alphaUserIds: [streamer.id, ...(await createUserIds(factories, 3))],
bravoUserIds: await createUserIds(factories, 4),
});
await factories.LiveStreamFactory.replaceAll([
{
userId: streamer.id,
twitch: STREAMER_TWITCH,
viewerCount: STREAM_VIEWER_COUNT,
},
]);
await streamsPage.goto();
await expect(streamsPage.streamerLink("QStreamer")).toBeVisible();
await expect(streamsPage.matchLink(match.id)).toHaveAttribute(
"href",
sendouQMatchPage(match.id),
);
await expect(streamsPage.twitchLink(STREAMER_TWITCH)).toBeVisible();
await expect(streamsPage.viewerCount(STREAM_VIEWER_COUNT)).toBeVisible();
});
test("rules, info and tiers pages render their content", async ({ page }) => {
const rulesPage = new QRulesPage(page);
await rulesPage.goto();
await expectNoErrorPage(page);
await expect(rulesPage.locators.heading).toBeVisible();
const infoPage = new QInfoPage(page);
await infoPage.goto();
await expectNoErrorPage(page);
await expect(infoPage.locators.generalInfoHeading).toBeVisible();
const tiersPage = new TiersPage(page);
await tiersPage.goto();
await expectNoErrorPage(page);
await expect(tiersPage.tierImage("LEVIATHAN")).toBeVisible();
await expect(tiersPage.tierImage("IRON")).toBeVisible();
});
});

View File

@@ -5,6 +5,7 @@ import { serializeLutiDiv } from "~/features/scrims/scrims-utils";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { toDBBoolean } from "~/utils/sql";
import { scrimPage } from "~/utils/urls";
import type { Factories } from "./helpers/factories";
import {
expect,
@@ -157,6 +158,7 @@ test.describe("Scrims", () => {
test("accepts a request", async ({ page, factories }) => {
const post = await createPostWithRequest(factories, {
ownerUserId: ADMIN_ID,
requesterUserId: NZAP_TEST_ID,
});
await factories.NotificationFactory.create({
notification: {
@@ -190,6 +192,15 @@ test.describe("Scrims", () => {
const scrim = await scrims.openFirstBookedScrim();
await expect(scrim.locators.subtitle).toBeVisible();
// the requester got notified of the scheduled scrim, linking to its page
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await notifications.open();
await notifications.openNotification("New scrim scheduled vs.");
await expect(page).toHaveURL(scrimPage(post.id));
});
test("auto-cancels overlapping pending scrims when a scrim is booked", async ({
@@ -302,6 +313,15 @@ test.describe("Scrims", () => {
await impersonate(page, ADMIN_ID);
await scrims.goto();
const notifications = new NotificationPopover(page);
await notifications.open();
await expect(
notifications.notification("N-ZAP requested a scrim"),
).toBeVisible();
await notifications.close();
await expect(scrims.post("+2h")).toBeVisible();
await expect(scrims.locators.tournamentPopover).toBeVisible();
await expect(scrims.post("Ready to scrim! Let's do this.")).toBeVisible();

View File

@@ -4,6 +4,7 @@ import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { WeaponBuildsPage } from "./pages/builds/weapon-builds-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
import { FaqPage } from "./pages/info/faq-page";
import { SELECTED_MAP_CLASS } from "./pages/settings/map-mode-preferences-field";
import { MatchProfilePage } from "./pages/settings/match-profile-page";
import { SettingsPage } from "./pages/settings/settings-page";
@@ -69,6 +70,65 @@ test.describe("Settings", () => {
expect(newTime).not.toBe(initialTime);
expect(newTime).toContain(":");
});
test("switches language to Japanese, persists it across pages and restores English", async ({
page,
}) => {
const settings = new SettingsPage(page);
const faq = new FaqPage(page);
await settings.goto("locale");
await expect(settings.locators.pageHeading).toHaveText("Settings");
await settings.selectLanguage("日本語");
await expect(settings.locators.pageHeading).toHaveText("設定");
// full page load without the lng param -> the language comes from the cookie;
// the faq namespace is registered via the route handle
await faq.goto();
await expect(faq.question("プラスサーバーとはなんですか?")).toBeVisible();
await settings.goto("locale");
await expect(settings.locators.pageHeading).toHaveText("設定");
await settings.selectLanguage("English");
await expect(settings.locators.pageHeading).toHaveText("Settings");
await faq.goto();
await expect(faq.question("What is the Plus Server?")).toBeVisible();
});
test("persists sound settings and dark/light theme across reload", async ({
page,
}) => {
await impersonate(page);
const settings = new SettingsPage(page);
await settings.goto("sounds");
const likeSound = settings.soundCheckbox("Group invitation received");
await expect(likeSound).toBeChecked();
await likeSound.click();
await expect(likeSound).not.toBeChecked();
await settings.setSoundVolume("37");
// default resolves to light (auto + light color scheme) so dark is a real change
await settings.goto("theme");
await settings.setTheme("Dark");
await expect(settings.locators.htmlRoot).toHaveClass(/dark/);
await settings.reload();
await expect(settings.locators.htmlRoot).toHaveClass(/dark/);
await settings.goto("sounds");
await expect(likeSound).not.toBeChecked();
await expect(settings.locators.volumeSlider).toHaveValue("37");
await settings.goto("theme");
await settings.setTheme("Light");
await expect(settings.locators.htmlRoot).toHaveClass(/light/);
await expect(settings.locators.htmlRoot).not.toHaveClass(/dark/);
});
});
test.describe("Match profile map preferences", () => {

View File

@@ -19,6 +19,7 @@ import { UserPage } from "./pages/user/user-page";
const TEAM_NAME = "Alliance Rogue";
const SECONDARY_TEAM_NAME = "Team Olive";
const ROSTER_SIZE = 4;
const TOURNAMENT_NAME = "In The Zone 30";
test.describe("New team creation", () => {
test("creates new team", async ({ page }) => {
@@ -192,6 +193,62 @@ test.describe("Team page", () => {
await expect(firstRow.locators.username).toHaveText(secondName);
});
test("shows a finalized tournament placement on the results page", async ({
page,
factories,
}) => {
const teammates = await factories.UserFactory.createMany(ROSTER_SIZE - 1);
const memberUserIds = [
ADMIN_ID,
...teammates.map((teammate) => teammate.id),
];
const { id: teamId, customUrl } = await factories.TeamFactory.create({
name: TEAM_NAME,
memberUserIds,
});
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
});
const linkedTournamentTeam = await factories.TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds,
team: { name: TEAM_NAME, prefersNotToHost: 0, teamId },
},
{ isCheckedIn: true },
);
const opponents = await factories.UserFactory.createMany(ROSTER_SIZE);
await factories.TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds: opponents.map((opponent) => opponent.id),
},
{ isCheckedIn: true },
);
const matches = await factories.TournamentFactory.playOut(
tournament.id,
"all",
);
await impersonate(page, ADMIN_ID);
const team = new TeamPage(page);
await team.goto(customUrl);
const results = await team.openResults();
await expect(page).toHaveURL(/\/results/);
const wonTheFinal = matches.some(
(match) => match.winnerTeamId === linkedTournamentTeam.id,
);
await expect(results.resultRow(TOURNAMENT_NAME)).toContainText("/ 2");
await expect(
results.placement(TOURNAMENT_NAME, wonTheFinal ? "1st" : "2nd"),
).toBeVisible();
});
test("deletes team", async ({ page, factories }) => {
const { customUrl } = await factories.TeamFactory.create({
name: TEAM_NAME,

View File

@@ -2,14 +2,14 @@ import { subMinutes } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { expect, impersonate, test } from "./helpers/playwright";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentDivisionsPage } from "./pages/tournament/tournament-divisions-page";
import { TournamentSeedsPage } from "./pages/tournament/tournament-seeds-page";
const TEAMS_PER_DIVISION = 6;
const ROSTER_SIZE = 4;
test.describe("Tournament A/B divisions", () => {
test("assigns 6A/6B, starts bracket, renders 36 matches across 6 rounds and two standings tables", async ({
test("assigns 6A/6B, lists the division on the divisions page, starts bracket, renders 36 matches across 6 rounds and two standings tables", async ({
page,
factories,
}) => {
@@ -19,19 +19,22 @@ test.describe("Tournament A/B divisions", () => {
const players = await factories.UserFactory.createMany(
teamCount * ROSTER_SIZE,
);
const tournament = await factories.TournamentFactory.create({
authorId: NZAP_TEST_ID,
startTimes: [dateToDatabaseTimestamp(subMinutes(new Date(), 30))],
mapPickingStyle: "AUTO_ALL",
bracketProgression: [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: { hasAbDivisions: true, teamsPerGroup: teamCount },
},
],
});
const tournament = await factories.TournamentFactory.create(
{
authorId: NZAP_TEST_ID,
startTimes: [dateToDatabaseTimestamp(subMinutes(new Date(), 30))],
mapPickingStyle: "AUTO_ALL",
bracketProgression: [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: { hasAbDivisions: true, teamsPerGroup: teamCount },
},
],
},
{ isLeague: true },
);
for (let i = 0; i < teamCount; i++) {
await factories.TournamentTeamFactory.create(
{
@@ -62,8 +65,16 @@ test.describe("Tournament A/B divisions", () => {
await seeds.saveAbDivisions();
const brackets = new TournamentBracketsPage(page);
await brackets.goto(tournament.id);
// a league's brackets are reached through its divisions page
const divisions = new TournamentDivisionsPage(page);
await divisions.goto(tournament.id);
await expect(divisions.locators.divisionLinks).toHaveCount(1);
await expect(divisions.divisionLink("Groups stage")).toContainText(
`${teamCount} teams`,
);
const brackets = await divisions.openDivision("Groups stage");
await brackets.finalize();
await expect(brackets.locators.bracketsViewer).toBeVisible();

View File

@@ -17,6 +17,7 @@ import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page";
import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page";
import { TournamentSubsPage } from "./pages/tournament/tournament-subs-page";
import { TournamentTeamPage } from "./pages/tournament/tournament-team-page";
import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page";
const ROSTER_SIZE = 4;
const CAPTAIN_DISCORD_ID = "1234567890123456789";
@@ -104,7 +105,7 @@ test.describe("Tournament admin team management", () => {
await expect(audit.eventCell("Team registered")).toBeVisible();
});
test("imports a roster from another tournament into the registration form", async ({
test("imports a roster from another tournament and registers it", async ({
page,
factories,
}) => {
@@ -116,7 +117,14 @@ test.describe("Tournament admin team management", () => {
authorId: NZAP_TEST_ID,
startTimes: [dateToDatabaseTimestamp(subDays(new Date(), 2))],
});
const importedRoster = await factories.UserFactory.createMany(ROSTER_SIZE);
const importedRosterNames = Array.from(
{ length: ROSTER_SIZE },
(_, i) => `Imported Player ${i + 1}`,
);
const importedRoster = await factories.UserFactory.createMany(
ROSTER_SIZE,
(i) => ({ discordName: importedRosterNames[i] }),
);
await factories.TournamentTeamFactory.create({
tournamentId: pastTournament.id,
team: pickUpTeam("Imported Legends"),
@@ -134,11 +142,27 @@ test.describe("Tournament admin team management", () => {
await registration.importFirstTeamFrom("Paddling Pool");
// the dialog closes and the imported roster's name prefills the form
// the dialog closes and the imported roster prefills the form
await expect(registration.locators.importDialogHeading).toHaveCount(0);
await expect(registration.locators.teamNameInput).toHaveValue(
"Imported Legends",
);
for (const name of importedRosterNames) {
await expect(registration.memberWithName(name)).toBeVisible();
}
await registration.save();
const admin = new TournamentAdminPage(page);
await expect(admin.teamName("Imported Legends")).toBeVisible();
// the imported team registered with its full roster
const teamsPage = new TournamentTeamsPage(page);
await teamsPage.goto(tournament.id);
await expect(teamsPage.teamNamed("Imported Legends")).toBeVisible();
for (const name of importedRosterNames) {
await expect(teamsPage.memberNamed(name)).toBeVisible();
}
});
test("sets the counterpick map pool of a team that has none, rejects an incomplete edit to it and then edits it", async ({

View File

@@ -61,6 +61,17 @@ test.describe("Tournament", () => {
await register.saveCounterpickMaps();
await expect(register.stepCheckmark(3)).toBeVisible();
// adding to the roster notified the added member
await impersonate(page, friends[0].id);
await navigate({ page, url: "/" });
const notifications = new NotificationPopover(page);
await notifications.open();
await expect(
notifications.notification(`Added to a team (${TEAM_NAME})`),
).toBeVisible();
});
test("checks in and appears on the bracket", async ({ page, factories }) => {

View File

@@ -6,7 +6,13 @@ import { decompressFromBase64 } from "~/utils/compression";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { TROPHIES_PAGE } from "~/utils/urls";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import {
expect,
impersonate,
isNotVisible,
navigate,
test,
} from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
import { NewTrophyPage } from "./pages/trophies/new-trophy-page";
import { TrophiesPage } from "./pages/trophies/trophies-page";
@@ -207,6 +213,16 @@ test.describe("Trophies", () => {
await expect(
reviewed.row(declinedName).getByText("Declined by Sendou"),
).toBeVisible();
// declining notified the submitter
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await notifications.open();
await expect(
notifications.notification(`Your trophy ${declinedName} was declined`),
).toBeVisible();
});
});

View File

@@ -3,7 +3,11 @@ import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import {
FULL_GROUP_SIZE,
SENDOUQ_BEST_OF,
} from "~/features/sendouq/q-constants";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { SettingsPage } from "./pages/settings/settings-page";
@@ -14,6 +18,11 @@ import { UserSeasonsPage } from "./pages/user/user-seasons-page";
/** The only season the e2e seasons list has finished, i.e. the exportable one. */
const FINISHED_SEASON = 0;
const LUNA_BLASTER: MainWeaponId = 200;
const SCORCH_GORGE: StageId = 0;
/** Maps of a seeded concluded match that get played: alpha wins them all straight. */
const PLAYED_MAPS_COUNT = Math.ceil(SENDOUQ_BEST_OF / 2);
test.describe("User page", () => {
test("uses badge pagination", async ({ page, factories }) => {
await factories.BadgeFactory.create(
@@ -220,6 +229,137 @@ test.describe("User page", () => {
await expect(userPage.weaponPoolImage(id, i + 1)).toBeVisible();
}
});
test("chooses result highlights which the results list then shows by default", async ({
page,
factories,
}) => {
const zonesEvent = await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: "In The Zone 30",
});
const poolEvent = await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
name: "Paddling Pool 253",
});
for (const [event, placement] of [
[zonesEvent, 2],
[poolEvent, 1],
] as const) {
await factories.CalendarEventResultFactory.create({
eventId: event.id,
participantCount: 16,
results: [
{
teamName: "Team Olive",
placement,
players: [
{ userId: ADMIN_ID, name: null },
{ userId: null, name: "Mako" },
{ userId: null, name: "Marie" },
{ userId: null, name: "Callie" },
],
},
],
});
}
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
const resultsPage = await userPage.openResults();
await expect(resultsPage.eventName("In The Zone 30")).toBeVisible();
await expect(resultsPage.eventName("Paddling Pool 253")).toBeVisible();
const highlightsPage = await resultsPage.openChooseHighlights();
await highlightsPage.resultCheckbox(/In The Zone 30/).check();
await highlightsPage.save();
await expect(resultsPage.eventName("In The Zone 30")).toBeVisible();
await isNotVisible(resultsPage.eventName("Paddling Pool 253"));
});
test("edits profile widgets, lists vods and shows season stats", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, {
patronTier: 2,
preferences: { newProfileEnabled: true },
});
await factories.VodFactory.createMany(2, (index) => ({
submitterUserId: ADMIN_ID,
pov: { type: "USER" as const, userId: ADMIN_ID },
title: `Ranked grind episode ${index + 1}`,
}));
const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 2);
const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const match = await factories.SQMatchFactory.create(
{
alphaUserIds: [ADMIN_ID, NZAP_TEST_ID, ...mates.map((mate) => mate.id)],
bravoUserIds: enemies.map((enemy) => enemy.id),
mapList: Array.from({ length: SENDOUQ_BEST_OF }, () => ({
mode: "SZ" as const,
stageId: SCORCH_GORGE,
source: "BOTH" as const,
})),
},
{ isConcluded: true },
);
await factories.SQReportedWeaponFactory.createMany(
PLAYED_MAPS_COUNT,
(index) => ({
groupMatchId: match.id,
mapIndex: index,
userId: ADMIN_ID,
weaponSplId: LUNA_BLASTER,
}),
);
await impersonate(page);
const userPage = new UserPage(page);
await userPage.goto(ADMIN_DISCORD_ID);
const editWidgets = await userPage.openEditWidgets();
await editWidgets.addWidget("bio");
await editWidgets.fillBio("Reformed Hydra main");
await editWidgets.addWidget("join-date");
await editWidgets.save();
await expect(userPage.widgetHeading("Bio")).toBeVisible();
await expect(
userPage.text("Reformed Hydra main").filter({ visible: true }),
).toBeVisible();
await expect(userPage.widgetHeading("Member #")).toBeVisible();
// admin is the first user created, so their join order is 1
await expect(
userPage.exactText("#1").filter({ visible: true }),
).toBeVisible();
const vodsPage = await userPage.openVods();
await expect(vodsPage.vodTitle("Ranked grind episode 1")).toBeVisible();
await expect(vodsPage.vodTitle("Ranked grind episode 2")).toBeVisible();
const seasonsPage = new UserSeasonsPage(page);
await seasonsPage.goto(ADMIN_DISCORD_ID);
await seasonsPage.openStatsTab("Weapons");
await expect(
seasonsPage.weaponUsageImage("Luna Blaster (100%)"),
).toBeVisible();
await seasonsPage.openStatsTab("Stages");
await expect(
seasonsPage.stageRecord(`${PLAYED_MAPS_COUNT}W 0L`),
).toBeVisible();
await seasonsPage.openStatsTab("Teammates");
await expect(seasonsPage.playerLink("N-ZAP")).toBeVisible();
});
});
/**

View File

@@ -108,7 +108,7 @@ test.describe("VoDs page", () => {
}
});
test("edits vod", async ({ page, factories }) => {
test("edits and deletes vod", async ({ page, factories }) => {
const existingVod = await factories.VodFactory.create({
submitterUserId: ADMIN_ID,
pov: { type: "USER", userId: ADMIN_ID },
@@ -135,6 +135,15 @@ test.describe("VoDs page", () => {
await expect(page).toHaveURL(vodVideoPage(existingVod.id));
await expect(vod.weaponImage(LUNA_BLASTER)).toBeVisible();
await vod.delete();
await expect(page).toHaveURL(/\/u\/.+\/vods/);
const vods = new VodsPage(page);
await vods.goto();
await expect(vods.locators.noVodsText).toBeVisible();
});
test("operates vod filters", async ({ page, factories }) => {