mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 19:16:09 -05:00
Improve E2E test coverage
This commit is contained in:
@@ -244,6 +244,7 @@ export function BuildCard({
|
||||
className={styles.smallText}
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
testId="delete-build"
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
</>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
14
app/features/api-private/routes/set-plus-voting-active.ts
Normal file
14
app/features/api-private/routes/set-plus-voting-active.ts
Normal 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);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -279,6 +279,7 @@ function ChangeSortingDialog({
|
||||
icon={<Trash />}
|
||||
variant="minimal-destructive"
|
||||
onPress={deleteLastSorting}
|
||||
data-testid="delete-sorting-button"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
172
e2e/admin.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
71
e2e/auth.spec.ts
Normal 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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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
155
e2e/leaderboards.spec.ts
Normal 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
61
e2e/map-tools.spec.ts
Normal 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
88
e2e/mobile-smoke.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
146
e2e/pages/admin/admin-actions-page.ts
Normal file
146
e2e/pages/admin/admin-actions-page.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
72
e2e/pages/admin/admin-streams-page.ts
Normal file
72
e2e/pages/admin/admin-streams-page.ts
Normal 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$/ }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
31
e2e/pages/articles/article-page.ts
Normal file
31
e2e/pages/articles/article-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
e2e/pages/articles/articles-page.ts
Normal file
26
e2e/pages/articles/articles-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
e2e/pages/auth/discord-authorize.ts
Normal file
47
e2e/pages/auth/discord-authorize.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
e2e/pages/auth/log-in-link-page.ts
Normal file
21
e2e/pages/auth/log-in-link-page.ts
Normal 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() };
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
27
e2e/pages/builds/build-stats-page.ts
Normal file
27
e2e/pages/builds/build-stats-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
e2e/pages/builds/popular-builds-page.ts
Normal file
35
e2e/pages/builds/popular-builds-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
20
e2e/pages/info/contributions-page.ts
Normal file
20
e2e/pages/info/contributions-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
e2e/pages/info/faq-page.ts
Normal file
20
e2e/pages/info/faq-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
e2e/pages/info/links-page.ts
Normal file
20
e2e/pages/info/links-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
e2e/pages/info/support-page.ts
Normal file
24
e2e/pages/info/support-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
e2e/pages/layout/error-page.ts
Normal file
35
e2e/pages/layout/error-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
15
e2e/pages/layout/top-right-buttons.ts
Normal file
15
e2e/pages/layout/top-right-buttons.ts
Normal 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" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
53
e2e/pages/leaderboards/leaderboards-page.ts
Normal file
53
e2e/pages/leaderboards/leaderboards-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
e2e/pages/maps/map-list-generator-page.ts
Normal file
55
e2e/pages/maps/map-list-generator-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
23
e2e/pages/org/organization-stats-page.ts
Normal file
23
e2e/pages/org/organization-stats-page.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
61
e2e/pages/plans/map-planner-page.ts
Normal file
61
e2e/pages/plans/map-planner-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
30
e2e/pages/plus/new-suggestion-page.ts
Normal file
30
e2e/pages/plus/new-suggestion-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
e2e/pages/plus/suggestion-comment-page.ts
Normal file
34
e2e/pages/plus/suggestion-comment-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
e2e/pages/plus/suggestions-page.ts
Normal file
73
e2e/pages/plus/suggestions-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
e2e/pages/plus/voting-page.ts
Normal file
31
e2e/pages/plus/voting-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
e2e/pages/plus/voting-results-page.ts
Normal file
46
e2e/pages/plus/voting-results-page.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
e2e/pages/scanner/scanner-page.ts
Normal file
16
e2e/pages/scanner/scanner-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
e2e/pages/sendouq/q-info-page.ts
Normal file
19
e2e/pages/sendouq/q-info-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
e2e/pages/sendouq/q-rules-page.ts
Normal file
19
e2e/pages/sendouq/q-rules-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
e2e/pages/sendouq/q-streams-page.ts
Normal file
35
e2e/pages/sendouq/q-streams-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
20
e2e/pages/sendouq/tiers-page.ts
Normal file
20
e2e/pages/sendouq/tiers-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
26
e2e/pages/team/team-results-page.ts
Normal file
26
e2e/pages/team/team-results-page.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
33
e2e/pages/tournament/tournament-divisions-page.ts
Normal file
33
e2e/pages/tournament/tournament-divisions-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
37
e2e/pages/user/user-edit-widgets-page.ts
Normal file
37
e2e/pages/user/user-edit-widgets-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
28
e2e/pages/user/user-results-highlights-page.ts
Normal file
28
e2e/pages/user/user-results-highlights-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
20
e2e/pages/user/user-vods-page.ts
Normal file
20
e2e/pages/user/user-vods-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
209
e2e/plus.spec.ts
Normal 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
161
e2e/public-pages.spec.ts
Normal 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
65
e2e/q-pages.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user