From 19adeef65f5f44f3e68cede0ce805f3d156fd31e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:01:39 +0300 Subject: [PATCH] Improve E2E test coverage --- app/components/BuildCard.tsx | 1 + app/db/seed/factories/TournamentFactory.ts | 16 +- .../api-private/routes/refresh-caches.ts | 2 + .../routes/set-plus-voting-active.ts | 14 ++ app/features/art/components/ArtGrid.tsx | 8 +- app/features/plus-voting/core/voting-time.ts | 13 ++ .../user-page/routes/u.$identifier.builds.tsx | 1 + .../routes/u.$identifier.edit-widgets.tsx | 1 + app/modules/redirects/routes/$.ts | 6 + app/routes.ts | 4 + e2e/admin.spec.ts | 172 ++++++++++++++ e2e/art.spec.ts | 22 +- e2e/auth.spec.ts | 71 ++++++ e2e/builds.spec.ts | 107 ++++++++- e2e/calendar.spec.ts | 32 ++- e2e/friends.spec.ts | 8 + e2e/helpers/factories.ts | 30 +++ e2e/helpers/playwright.ts | 42 ++++ e2e/leaderboards.spec.ts | 155 +++++++++++++ e2e/map-tools.spec.ts | 61 +++++ e2e/mobile-smoke.spec.ts | 88 ++++++++ e2e/org.spec.ts | 56 ++++- e2e/pages/admin/admin-actions-page.ts | 146 ++++++++++++ e2e/pages/admin/admin-streams-page.ts | 72 ++++++ e2e/pages/art/user-art-page.ts | 16 +- e2e/pages/articles/article-page.ts | 31 +++ e2e/pages/articles/articles-page.ts | 26 +++ e2e/pages/auth/discord-authorize.ts | 47 ++++ e2e/pages/auth/log-in-link-page.ts | 21 ++ e2e/pages/ban/admin-ban-page.ts | 23 +- e2e/pages/builds/build-stats-page.ts | 27 +++ e2e/pages/builds/popular-builds-page.ts | 35 +++ e2e/pages/builds/user-builds-page.ts | 29 ++- e2e/pages/builds/weapon-builds-page.ts | 2 + e2e/pages/calendar/calendar-event-page.ts | 21 +- e2e/pages/calendar/calendar-page.ts | 18 +- e2e/pages/info/contributions-page.ts | 20 ++ e2e/pages/info/faq-page.ts | 20 ++ e2e/pages/info/links-page.ts | 20 ++ e2e/pages/info/support-page.ts | 24 ++ e2e/pages/layout/error-page.ts | 35 +++ e2e/pages/layout/mobile-nav.ts | 3 + e2e/pages/layout/side-nav.ts | 4 + e2e/pages/layout/top-right-buttons.ts | 15 ++ e2e/pages/leaderboards/leaderboards-page.ts | 53 +++++ e2e/pages/maps/map-list-generator-page.ts | 55 +++++ e2e/pages/org/new-organization-page.ts | 5 + e2e/pages/org/organization-page.ts | 7 + e2e/pages/org/organization-stats-page.ts | 23 ++ e2e/pages/plans/map-planner-page.ts | 61 +++++ e2e/pages/plus/new-suggestion-page.ts | 30 +++ e2e/pages/plus/suggestion-comment-page.ts | 34 +++ e2e/pages/plus/suggestions-page.ts | 73 ++++++ e2e/pages/plus/voting-page.ts | 31 +++ e2e/pages/plus/voting-results-page.ts | 46 ++++ e2e/pages/scanner/scanner-page.ts | 16 ++ e2e/pages/sendouq/q-info-page.ts | 19 ++ e2e/pages/sendouq/q-rules-page.ts | 19 ++ e2e/pages/sendouq/q-streams-page.ts | 35 +++ e2e/pages/sendouq/tiers-page.ts | 20 ++ e2e/pages/settings/settings-page.ts | 58 ++++- e2e/pages/team/team-page.ts | 7 + e2e/pages/team/team-results-page.ts | 26 +++ .../tournament-admin-registration-page.ts | 7 + .../tournament/tournament-divisions-page.ts | 33 +++ e2e/pages/tournament/tournament-page.ts | 8 + e2e/pages/user/user-edit-widgets-page.ts | 37 ++++ e2e/pages/user/user-page.ts | 28 +++ .../user/user-results-highlights-page.ts | 28 +++ e2e/pages/user/user-results-page.ts | 14 ++ e2e/pages/user/user-seasons-page.ts | 19 ++ e2e/pages/user/user-vods-page.ts | 20 ++ e2e/pages/vods/vod-page.ts | 12 +- e2e/plus.spec.ts | 209 ++++++++++++++++++ e2e/public-pages.spec.ts | 161 ++++++++++++++ e2e/q-pages.spec.ts | 65 ++++++ e2e/scrims.spec.ts | 20 ++ e2e/settings.spec.ts | 60 +++++ e2e/team.spec.ts | 57 +++++ e2e/tournament-ab-divisions.spec.ts | 45 ++-- e2e/tournament-admin.spec.ts | 30 ++- e2e/tournament.spec.ts | 11 + e2e/trophies.spec.ts | 18 +- e2e/user-page.spec.ts | 142 +++++++++++- e2e/vods.spec.ts | 11 +- 85 files changed, 3161 insertions(+), 57 deletions(-) create mode 100644 app/features/api-private/routes/set-plus-voting-active.ts create mode 100644 e2e/admin.spec.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/leaderboards.spec.ts create mode 100644 e2e/map-tools.spec.ts create mode 100644 e2e/mobile-smoke.spec.ts create mode 100644 e2e/pages/admin/admin-actions-page.ts create mode 100644 e2e/pages/admin/admin-streams-page.ts create mode 100644 e2e/pages/articles/article-page.ts create mode 100644 e2e/pages/articles/articles-page.ts create mode 100644 e2e/pages/auth/discord-authorize.ts create mode 100644 e2e/pages/auth/log-in-link-page.ts create mode 100644 e2e/pages/builds/build-stats-page.ts create mode 100644 e2e/pages/builds/popular-builds-page.ts create mode 100644 e2e/pages/info/contributions-page.ts create mode 100644 e2e/pages/info/faq-page.ts create mode 100644 e2e/pages/info/links-page.ts create mode 100644 e2e/pages/info/support-page.ts create mode 100644 e2e/pages/layout/error-page.ts create mode 100644 e2e/pages/layout/top-right-buttons.ts create mode 100644 e2e/pages/leaderboards/leaderboards-page.ts create mode 100644 e2e/pages/maps/map-list-generator-page.ts create mode 100644 e2e/pages/org/organization-stats-page.ts create mode 100644 e2e/pages/plans/map-planner-page.ts create mode 100644 e2e/pages/plus/new-suggestion-page.ts create mode 100644 e2e/pages/plus/suggestion-comment-page.ts create mode 100644 e2e/pages/plus/suggestions-page.ts create mode 100644 e2e/pages/plus/voting-page.ts create mode 100644 e2e/pages/plus/voting-results-page.ts create mode 100644 e2e/pages/scanner/scanner-page.ts create mode 100644 e2e/pages/sendouq/q-info-page.ts create mode 100644 e2e/pages/sendouq/q-rules-page.ts create mode 100644 e2e/pages/sendouq/q-streams-page.ts create mode 100644 e2e/pages/sendouq/tiers-page.ts create mode 100644 e2e/pages/team/team-results-page.ts create mode 100644 e2e/pages/tournament/tournament-divisions-page.ts create mode 100644 e2e/pages/user/user-edit-widgets-page.ts create mode 100644 e2e/pages/user/user-results-highlights-page.ts create mode 100644 e2e/pages/user/user-vods-page.ts create mode 100644 e2e/plus.spec.ts create mode 100644 e2e/public-pages.spec.ts create mode 100644 e2e/q-pages.spec.ts diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 65f5fa540..124b4d598 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -244,6 +244,7 @@ export function BuildCard({ className={styles.smallText} variant="minimal-destructive" type="submit" + testId="delete-build" /> diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index b29896c33..d1e70a3bf 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -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`json_set(settings, '$.isLeague', json('true'))`, + }) + .where("id", "=", tournament.id) + .execute(); + } + if (!tier) return; await TournamentRepository.upsertDivisionTier({ diff --git a/app/features/api-private/routes/refresh-caches.ts b/app/features/api-private/routes/refresh-caches.ts index f1dac6296..159ef9ebc 100644 --- a/app/features/api-private/routes/refresh-caches.ts +++ b/app/features/api-private/routes/refresh-caches.ts @@ -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(); diff --git a/app/features/api-private/routes/set-plus-voting-active.ts b/app/features/api-private/routes/set-plus-voting-active.ts new file mode 100644 index 000000000..6cdd856fe --- /dev/null +++ b/app/features/api-private/routes/set-plus-voting-active.ts @@ -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); +}; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 205910898..8bd4be9a6 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -205,7 +205,12 @@ function ImagePreview({ ["_action", "DELETE_ART"], ]} > - } variant="destructive" size="small" /> + } + variant="destructive" + size="small" + testId="delete-art-button" + /> @@ -262,6 +267,7 @@ function ImagePreview({ icon={} variant="destructive" size="small" + testId="unlink-art-button" /> ) : null} diff --git a/app/features/plus-voting/core/voting-time.ts b/app/features/plus-voting/core/voting-time.ts index edd4b138a..719b31267 100644 --- a/app/features/plus-voting/core/voting-time.ts +++ b/app/features/plus-voting/core/voting-time.ts @@ -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) { diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 2fdab891d..d53569639 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -279,6 +279,7 @@ function ChangeSortingDialog({ icon={} variant="minimal-destructive" onPress={deleteLastSorting} + data-testid="delete-sorting-button" /> ) : null} diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx index 5c39729b1..938321392 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx @@ -269,6 +269,7 @@ function AvailableWidgetsList({ variant="outlined" onPress={() => onAddWidget(widget.id)} isDisabled={isSelected || isMaxReached} + testId={`add-widget-${widget.id}`} > {t("user:widgets.add")} diff --git a/app/modules/redirects/routes/$.ts b/app/modules/redirects/routes/$.ts index f2ae9ff69..f9b0dba16 100644 --- a/app/modules/redirects/routes/$.ts +++ b/app/modules/redirects/routes/$.ts @@ -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; +} diff --git a/app/routes.ts b/app/routes.ts index c46d9a1a9..413738fc4 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 000000000..a8e51d272 --- /dev/null +++ b/e2e/admin.spec.ts @@ -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(); + }); +}); diff --git a/e2e/art.spec.ts b/e2e/art.spec.ts index c627fcbb3..1687d4b41 100644 --- a/e2e/art.spec.ts +++ b/e2e/art.spec.ts @@ -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); }); }); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 000000000..4bfc5373b --- /dev/null +++ b/e2e/auth.spec.ts @@ -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$/, + ); + }); +}); diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 6948c8943..c9fbf5591 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -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"); + }); }); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 45691fec5..3c2b7c4e9 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -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 ({ diff --git a/e2e/friends.spec.ts b/e2e/friends.spec.ts index b18c7e99d..64f06bd1c 100644 --- a/e2e/friends.spec.ts +++ b/e2e/friends.spec.ts @@ -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(); diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index f5358b52e..1e1ac0dac 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -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" ), diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index bd7638ab3..654ed26d3 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -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", { diff --git a/e2e/leaderboards.spec.ts b/e2e/leaderboards.spec.ts new file mode 100644 index 000000000..819590541 --- /dev/null +++ b/e2e/leaderboards.spec.ts @@ -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", + }); +} diff --git a/e2e/map-tools.spec.ts b/e2e/map-tools.spec.ts new file mode 100644 index 000000000..380dff67f --- /dev/null +++ b/e2e/map-tools.spec.ts @@ -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); + }); +}); diff --git a/e2e/mobile-smoke.spec.ts b/e2e/mobile-smoke.spec.ts new file mode 100644 index 000000000..cb14681d7 --- /dev/null +++ b/e2e/mobile-smoke.spec.ts @@ -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(); + }); +}); diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 3389e97b0..6d2c0a039 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -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, diff --git a/e2e/pages/admin/admin-actions-page.ts b/e2e/pages/admin/admin-actions-page.ts new file mode 100644 index 000000000..f5a92bdca --- /dev/null +++ b/e2e/pages/admin/admin-actions-page.ts @@ -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 })); + } +} diff --git a/e2e/pages/admin/admin-streams-page.ts b/e2e/pages/admin/admin-streams-page.ts new file mode 100644 index 000000000..4fbfaa1a3 --- /dev/null +++ b/e2e/pages/admin/admin-streams-page.ts @@ -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$/ }), + }); + } +} diff --git a/e2e/pages/art/user-art-page.ts b/e2e/pages/art/user-art-page.ts index 237723ee8..594569084 100644 --- a/e2e/pages/art/user-art-page.ts +++ b/e2e/pages/art/user-art-page.ts @@ -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); + } } diff --git a/e2e/pages/articles/article-page.ts b/e2e/pages/articles/article-page.ts new file mode 100644 index 000000000..97115e40c --- /dev/null +++ b/e2e/pages/articles/article-page.ts @@ -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); + } +} diff --git a/e2e/pages/articles/articles-page.ts b/e2e/pages/articles/articles-page.ts new file mode 100644 index 000000000..bccba479b --- /dev/null +++ b/e2e/pages/articles/articles-page.ts @@ -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); + } +} diff --git a/e2e/pages/auth/discord-authorize.ts b/e2e/pages/auth/discord-authorize.ts new file mode 100644 index 000000000..631ec7ad3 --- /dev/null +++ b/e2e/pages/auth/discord-authorize.ts @@ -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: "

Discord authorize stub

", + }); + }); + 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); + } +} diff --git a/e2e/pages/auth/log-in-link-page.ts b/e2e/pages/auth/log-in-link-page.ts new file mode 100644 index 000000000..968b3d70b --- /dev/null +++ b/e2e/pages/auth/log-in-link-page.ts @@ -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() }; + } +} diff --git a/e2e/pages/ban/admin-ban-page.ts b/e2e/pages/ban/admin-ban-page.ts index 82a7e0afc..eee5982d9 100644 --- a/e2e/pages/ban/admin-ban-page.ts +++ b/e2e/pages/ban/admin-ban-page.ts @@ -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) { diff --git a/e2e/pages/builds/build-stats-page.ts b/e2e/pages/builds/build-stats-page.ts new file mode 100644 index 000000000..0ac0213c1 --- /dev/null +++ b/e2e/pages/builds/build-stats-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/builds/popular-builds-page.ts b/e2e/pages/builds/popular-builds-page.ts new file mode 100644 index 000000000..e99a7efae --- /dev/null +++ b/e2e/pages/builds/popular-builds-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/builds/user-builds-page.ts b/e2e/pages/builds/user-builds-page.ts index bcc491c8d..8253cf197 100644 --- a/e2e/pages/builds/user-builds-page.ts +++ b/e2e/pages/builds/user-builds-page.ts @@ -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); + } } diff --git a/e2e/pages/builds/weapon-builds-page.ts b/e2e/pages/builds/weapon-builds-page.ts index f91edfcf6..3c7a7ae6d 100644 --- a/e2e/pages/builds/weapon-builds-page.ts +++ b/e2e/pages/builds/weapon-builds-page.ts @@ -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"), diff --git a/e2e/pages/calendar/calendar-event-page.ts b/e2e/pages/calendar/calendar-event-page.ts index bcbe2c53f..d05001452 100644 --- a/e2e/pages/calendar/calendar-event-page.ts +++ b/e2e/pages/calendar/calendar-event-page.ts @@ -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); + } } diff --git a/e2e/pages/calendar/calendar-page.ts b/e2e/pages/calendar/calendar-page.ts index 15d2ce603..bce94fc0a 100644 --- a/e2e/pages/calendar/calendar-page.ts +++ b/e2e/pages/calendar/calendar-page.ts @@ -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(); } diff --git a/e2e/pages/info/contributions-page.ts b/e2e/pages/info/contributions-page.ts new file mode 100644 index 000000000..4edfd5672 --- /dev/null +++ b/e2e/pages/info/contributions-page.ts @@ -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); + } +} diff --git a/e2e/pages/info/faq-page.ts b/e2e/pages/info/faq-page.ts new file mode 100644 index 000000000..dc5bf548d --- /dev/null +++ b/e2e/pages/info/faq-page.ts @@ -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); + } +} diff --git a/e2e/pages/info/links-page.ts b/e2e/pages/info/links-page.ts new file mode 100644 index 000000000..98001dc18 --- /dev/null +++ b/e2e/pages/info/links-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/info/support-page.ts b/e2e/pages/info/support-page.ts new file mode 100644 index 000000000..37f1dfd03 --- /dev/null +++ b/e2e/pages/info/support-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/layout/error-page.ts b/e2e/pages/layout/error-page.ts new file mode 100644 index 000000000..eab8e90d4 --- /dev/null +++ b/e2e/pages/layout/error-page.ts @@ -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); + } +} diff --git a/e2e/pages/layout/mobile-nav.ts b/e2e/pages/layout/mobile-nav.ts index 66d44160c..f0f91b602 100644 --- a/e2e/pages/layout/mobile-nav.ts +++ b/e2e/pages/layout/mobile-nav.ts @@ -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']"), }; } diff --git a/e2e/pages/layout/side-nav.ts b/e2e/pages/layout/side-nav.ts index 8feb8136b..ea61cdea8 100644 --- a/e2e/pages/layout/side-nav.ts +++ b/e2e/pages/layout/side-nav.ts @@ -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", { diff --git a/e2e/pages/layout/top-right-buttons.ts b/e2e/pages/layout/top-right-buttons.ts new file mode 100644 index 000000000..5917bdaf3 --- /dev/null +++ b/e2e/pages/layout/top-right-buttons.ts @@ -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" }), + }; + } +} diff --git a/e2e/pages/leaderboards/leaderboards-page.ts b/e2e/pages/leaderboards/leaderboards-page.ts new file mode 100644 index 000000000..4e3658170 --- /dev/null +++ b/e2e/pages/leaderboards/leaderboards-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/maps/map-list-generator-page.ts b/e2e/pages/maps/map-list-generator-page.ts new file mode 100644 index 000000000..f99947666 --- /dev/null +++ b/e2e/pages/maps/map-list-generator-page.ts @@ -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(); + } +} diff --git a/e2e/pages/org/new-organization-page.ts b/e2e/pages/org/new-organization-page.ts index 09716f8bc..7ce73c265 100644 --- a/e2e/pages/org/new-organization-page.ts +++ b/e2e/pages/org/new-organization-page.ts @@ -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() { diff --git a/e2e/pages/org/organization-page.ts b/e2e/pages/org/organization-page.ts index e2843d31d..4441203a8 100644 --- a/e2e/pages/org/organization-page.ts +++ b/e2e/pages/org/organization-page.ts @@ -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(); diff --git a/e2e/pages/org/organization-stats-page.ts b/e2e/pages/org/organization-stats-page.ts new file mode 100644 index 000000000..e4cd8c5ee --- /dev/null +++ b/e2e/pages/org/organization-stats-page.ts @@ -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"), + }); + } +} diff --git a/e2e/pages/plans/map-planner-page.ts b/e2e/pages/plans/map-planner-page.ts new file mode 100644 index 000000000..d55f02858 --- /dev/null +++ b/e2e/pages/plans/map-planner-page.ts @@ -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(); + } +} diff --git a/e2e/pages/plus/new-suggestion-page.ts b/e2e/pages/plus/new-suggestion-page.ts new file mode 100644 index 000000000..aa71d714a --- /dev/null +++ b/e2e/pages/plus/new-suggestion-page.ts @@ -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); + } +} diff --git a/e2e/pages/plus/suggestion-comment-page.ts b/e2e/pages/plus/suggestion-comment-page.ts new file mode 100644 index 000000000..85c239d8e --- /dev/null +++ b/e2e/pages/plus/suggestion-comment-page.ts @@ -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); + } +} diff --git a/e2e/pages/plus/suggestions-page.ts b/e2e/pages/plus/suggestions-page.ts new file mode 100644 index 000000000..073e301ea --- /dev/null +++ b/e2e/pages/plus/suggestions-page.ts @@ -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); + } +} diff --git a/e2e/pages/plus/voting-page.ts b/e2e/pages/plus/voting-page.ts new file mode 100644 index 000000000..f45a60b0d --- /dev/null +++ b/e2e/pages/plus/voting-page.ts @@ -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); + } +} diff --git a/e2e/pages/plus/voting-results-page.ts b/e2e/pages/plus/voting-results-page.ts new file mode 100644 index 000000000..12a1ed39c --- /dev/null +++ b/e2e/pages/plus/voting-results-page.ts @@ -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`, + }); + } +} diff --git a/e2e/pages/scanner/scanner-page.ts b/e2e/pages/scanner/scanner-page.ts new file mode 100644 index 000000000..e0b7e2f15 --- /dev/null +++ b/e2e/pages/scanner/scanner-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/sendouq/q-info-page.ts b/e2e/pages/sendouq/q-info-page.ts new file mode 100644 index 000000000..3c0ef2e47 --- /dev/null +++ b/e2e/pages/sendouq/q-info-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/sendouq/q-rules-page.ts b/e2e/pages/sendouq/q-rules-page.ts new file mode 100644 index 000000000..51ee0f75f --- /dev/null +++ b/e2e/pages/sendouq/q-rules-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/sendouq/q-streams-page.ts b/e2e/pages/sendouq/q-streams-page.ts new file mode 100644 index 000000000..572a52bce --- /dev/null +++ b/e2e/pages/sendouq/q-streams-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/sendouq/tiers-page.ts b/e2e/pages/sendouq/tiers-page.ts new file mode 100644 index 000000000..2896d586f --- /dev/null +++ b/e2e/pages/sendouq/tiers-page.ts @@ -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 }); + } +} diff --git a/e2e/pages/settings/settings-page.ts b/e2e/pages/settings/settings-page.ts index 91a234e74..7a3ea955d 100644 --- a/e2e/pages/settings/settings-page.ts +++ b/e2e/pages/settings/settings-page.ts @@ -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); } diff --git a/e2e/pages/team/team-page.ts b/e2e/pages/team/team-page.ts index a18200856..06e422168 100644 --- a/e2e/pages/team/team-page.ts +++ b/e2e/pages/team/team-page.ts @@ -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(); } diff --git a/e2e/pages/team/team-results-page.ts b/e2e/pages/team/team-results-page.ts new file mode 100644 index 000000000..6f678ad8b --- /dev/null +++ b/e2e/pages/team/team-results-page.ts @@ -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, + }); + } +} diff --git a/e2e/pages/tournament/tournament-admin-registration-page.ts b/e2e/pages/tournament/tournament-admin-registration-page.ts index 141332dc4..ee7e1dfab 100644 --- a/e2e/pages/tournament/tournament-admin-registration-page.ts +++ b/e2e/pages/tournament/tournament-admin-registration-page.ts @@ -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 `