Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,70 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { AnalyzerPage } from "./pages/analyzer/analyzer-page";
test.describe("Build Analyzer", () => {
test("analyzes a build and links to new build page with same abilities", async ({
page,
}) => {
await impersonate(page, NZAP_TEST_ID);
const analyzer = new AnalyzerPage(page);
await analyzer.goto();
await isNotVisible(analyzer.locators.newBuildPrompt);
await analyzer.selectWeapon("Splattershot");
await analyzer.openStatCategory("movement-category");
const swimSpeed = analyzer.statCard("swim-speed");
const swimSpeedSplattershot = (await swimSpeed.baseValue.textContent())!;
await analyzer.selectWeapon("Luna Blaster");
// Luna Blaster is a light weapon so it should have lower base swim speed than Splattershot
await expect(swimSpeed.baseValue).not.toHaveText(swimSpeedSplattershot);
// shows comparison value when you have relevant abilities selected
await isNotVisible(swimSpeed.buildValueTitle);
await analyzer.addAbility("SSU");
await expect(swimSpeed.buildValueTitle).toBeVisible();
// on new build page with preselected values
const buildForm = await analyzer.openNewBuildPrompt();
await expect(buildForm.gearSelect("HEAD")).toBeVisible();
// Check that Luna Blaster appears in the weapon pool list (not in dropdown options)
await expect(buildForm.weaponPoolItem("Luna Blaster")).toBeVisible();
await expect(buildForm.ability("SSU")).toBeVisible();
});
test("compares builds", async ({ page }) => {
const analyzer = new AnalyzerPage(page);
await analyzer.goto();
await analyzer.selectTab("build2");
const swimSpeedAbility = analyzer.selectedAbility("SSU");
await analyzer.addAbility("SSU");
await expect(swimSpeedAbility).toBeVisible();
// can't add abilities to build 2 if build 1 is empty
// -> they automatically go to build 1
await analyzer.selectTab("build2");
await isNotVisible(swimSpeedAbility);
await analyzer.addAbility("SSU");
await expect(swimSpeedAbility).toBeVisible();
await analyzer.addAbility("SSU");
await expect(swimSpeedAbility).toHaveCount(2);
await analyzer.selectTab("ap");
await expect(analyzer.apComparison(1).first()).toContainText("10AP");
await expect(analyzer.apComparison(2).first()).toContainText("13AP");
await expect(analyzer.statCard("swim-speed").buildValueTitle).toContainText(
"Build 2",
);
});
});

View File

@@ -0,0 +1,468 @@
import type { Page } from "@playwright/test";
import { addHours } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, test } from "./helpers/playwright";
import { ApiPage } from "./pages/api/api-page";
import { TournamentTeamPage } from "./pages/tournament/tournament-team-page";
const ROSTER_SIZE = 4;
const TOKEN_LENGTH = 20;
const authorized = (token: string) => ({
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
});
test.describe("Public API", () => {
test("OPTIONS preflight request returns 204 with CORS headers", async ({
page,
}) => {
const response = await page.request.fetch("/api/tournament/1", {
method: "OPTIONS",
});
expect(response.status()).toBe(204);
expect(response.headers()["access-control-allow-origin"]).toBe("*");
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
expect(response.headers()["access-control-allow-headers"]).toContain(
"Authorization",
);
});
test("GET request includes CORS headers in response", async ({ page }) => {
const response = await page.request.fetch("/api/tournament/1");
expect(response.headers()["access-control-allow-origin"]).toBe("*");
});
test("GET user IDs endpoint works without authentication", async ({
page,
}) => {
const response = await page.request.fetch(`/api/user/${ADMIN_ID}/ids`);
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.id).toBe(ADMIN_ID);
expect(data.discordId).toBeTruthy();
});
test("creates read API token and calls public endpoint", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] });
await impersonate(page);
const api = new ApiPage(page);
await api.goto();
const token = await api.generateToken("read");
expect(token).toHaveLength(TOKEN_LENGTH);
const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, {
headers: authorized(token),
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.id).toBe(ADMIN_ID);
expect(data.name).toBe("Sendou");
});
test("returns active SendouQ match for user", async ({ page, factories }) => {
const alpha = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const bravo = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
const match = await factories.SQMatchFactory.create({
alphaUserIds: alpha.map((user) => user.id),
bravoUserIds: bravo.map((user) => user.id),
});
const token = await readToken(factories, ADMIN_ID);
await impersonate(page);
const response = await page.request.fetch(
`/api/user/${alpha[0].id}/active-match`,
{ headers: authorized(token) },
);
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.matchId).toBe(match.id);
expect(data.lobby).toBe("sendouq");
expect(data.tournamentId).toBeNull();
expect(data.bracketIdx).toBeNull();
});
});
test.describe("Public API - Write endpoints", () => {
test("adds member to tournament team via API", async ({
page,
factories,
}) => {
const { tournamentId, teamId, token } =
await organizedTournament(factories);
const newMember = await factories.UserFactory.create();
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(200);
const teamPage = new TournamentTeamPage(page);
await teamPage.goto(tournamentId, teamId);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1);
});
test("removes member from tournament team via API", async ({
page,
factories,
}) => {
const { tournamentId, teamId, token } =
await organizedTournament(factories);
const newMember = await factories.UserFactory.create();
await impersonate(page, ADMIN_ID);
await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: authorized(token),
data: { userId: newMember.id },
},
);
const teamPage = new TournamentTeamPage(page);
await teamPage.goto(tournamentId, teamId);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/remove-member`,
{
method: "POST",
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(200);
await teamPage.goto(tournamentId, teamId);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE);
});
test("returns 401 for invalid token", async ({ page, factories }) => {
const { tournamentId, teamId } = await organizedTournament(factories);
const newMember = await factories.UserFactory.create();
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: authorized("invalid-token-12345"),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(401);
const data = await response.json();
expect(data.error).toBe("Invalid token");
});
test("returns 403 when using read token for write endpoint", async ({
page,
factories,
}) => {
const { tournamentId, teamId } = await organizedTournament(factories);
const newMember = await factories.UserFactory.create();
const token = await readToken(factories, ADMIN_ID);
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(403);
const data = await response.json();
expect(data.error).toBe("Write token required");
});
test("updates tournament seeds via API", async ({ page, factories }) => {
const { tournamentId, token } = await organizedTournament(factories, {
teamCount: 3,
});
await impersonate(page, ADMIN_ID);
const teamsResponse = await page.request.fetch(
`/api/tournament/${tournamentId}/teams`,
{ headers: authorized(token) },
);
expect(teamsResponse.status()).toBe(200);
const teams = await teamsResponse.json();
const reversedSeeds = teams
.map((team: { id: number }) => team.id)
.reverse();
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/seeds`,
{
method: "POST",
headers: authorized(token),
data: { tournamentTeamIds: reversedSeeds },
},
);
expect(response.status()).toBe(200);
const updatedTeamsResponse = await page.request.fetch(
`/api/tournament/${tournamentId}/teams`,
{ headers: authorized(token) },
);
const updatedTeams = await updatedTeamsResponse.json();
for (const [index, tournamentTeamId] of reversedSeeds.entries()) {
const team = updatedTeams.find(
(candidate: { id: number }) => candidate.id === tournamentTeamId,
);
expect(team.seed).toBe(index + 1);
}
});
test("updates tournament starting brackets via API", async ({
page,
factories,
}) => {
const { tournamentId, teamId, token } =
await organizedTournament(factories);
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/starting-brackets`,
{
method: "POST",
headers: authorized(token),
data: {
startingBrackets: [
{ tournamentTeamId: teamId, startingBracketIdx: 0 },
],
},
},
);
expect(response.status()).toBe(200);
});
test("upserts tournament team registration via API", async ({
page,
factories,
}) => {
const { tournamentId, token } = await organizedTournament(factories);
const roster = await factories.UserFactory.createMany(ROSTER_SIZE);
await impersonate(page, ADMIN_ID);
const createResponse = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/upsert`,
{
method: "POST",
headers: authorized(token),
data: {
name: "Api Pickup",
ownerUserId: roster[0].id,
members: roster.map((user) => ({ userId: user.id })),
},
},
);
expect(createResponse.status()).toBe(200);
const createdTeam = await teamByName(page, token, {
tournamentId,
name: "Api Pickup",
});
expect(createdTeam).toBeTruthy();
expect(createdTeam.members).toHaveLength(ROSTER_SIZE);
const editResponse = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/upsert`,
{
method: "POST",
headers: authorized(token),
data: {
tournamentTeamId: createdTeam.id,
name: "Api Pickup Edited",
ownerUserId: roster[0].id,
members: roster
.slice(0, ROSTER_SIZE - 1)
.map((user) => ({ userId: user.id })),
},
},
);
expect(editResponse.status()).toBe(200);
const editedTeam = await teamByName(page, token, {
tournamentId,
name: "Api Pickup Edited",
});
expect(editedTeam.id).toBe(createdTeam.id);
expect(editedTeam.members).toHaveLength(ROSTER_SIZE - 1);
});
test("returns 400 with field errors for invalid upsert registration body", async ({
page,
factories,
}) => {
const { tournamentId, token } = await organizedTournament(factories);
const owner = await factories.UserFactory.create();
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/upsert`,
{
method: "POST",
headers: authorized(token),
data: {
ownerUserId: owner.id,
members: [{ userId: owner.id }],
},
},
);
expect(response.status()).toBe(400);
const data = await response.json();
expect(data.fieldErrors.pickUpName).toBeTruthy();
});
test("updates member IGN via API", async ({ page, factories }) => {
const { tournamentId, teamId, memberUserIds, token } =
await organizedTournament(factories);
await impersonate(page, ADMIN_ID);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/update-member-ign`,
{
method: "POST",
headers: authorized(token),
data: { userId: memberUserIds[0], inGameName: "NewName#9999" },
},
);
expect(response.status()).toBe(200);
});
test("returns 400 when user is not the organizer of this tournament", async ({
page,
factories,
}) => {
const { tournamentId, teamId } = await organizedTournament(factories);
const outsider = await factories.UserFactory.create(null, {
roles: ["API_ACCESSER"],
});
const newMember = await factories.UserFactory.create();
const { token } = await factories.ApiTokenFactory.create({
userId: outsider.id,
type: "write",
});
await impersonate(page, outsider.id);
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(400);
const data = await response.json();
expect(data.error).toBe("Not an organizer");
});
});
/** A tournament the admin organizes, with teams registered and a write token to manage it with. */
async function organizedTournament(
factories: Factories,
{ teamCount = 1 }: { teamCount?: number } = {},
) {
await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] });
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
});
const teams = [];
for (let teamNth = 0; teamNth < teamCount; teamNth++) {
const roster = await factories.UserFactory.createMany(ROSTER_SIZE);
teams.push(
await factories.TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: roster.map((user) => user.id),
}),
);
}
const { token } = await factories.ApiTokenFactory.create({
userId: ADMIN_ID,
type: "write",
});
return {
tournamentId: tournament.id,
teamId: teams[0].id,
memberUserIds: teams[0].memberUserIds,
token,
};
}
async function teamByName(
page: Page,
token: string,
{ tournamentId, name }: { tournamentId: number; name: string },
) {
const response = await page.request.fetch(
`/api/tournament/${tournamentId}/teams`,
{ headers: authorized(token) },
);
expect(response.status()).toBe(200);
const teams = await response.json();
return teams.find((team: { name: string }) => team.name === name);
}
async function readToken(factories: Factories, userId: number) {
await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] });
const { token } = await factories.ApiTokenFactory.create({
userId,
type: "read",
});
return token;
}

View File

@@ -0,0 +1,84 @@
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 { NewArtPage } from "./pages/art/new-art-page";
import { UserArtPage } from "./pages/art/user-art-page";
import { ImageValidationPage } from "./pages/img-upload/image-validation-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = path.join(__dirname, "fixtures/test-image.png");
test.describe("Art", () => {
test("uploads art as NZAP, admin approves, art displays on user page", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(NZAP_TEST_ID, { roles: ["ARTIST"] });
await impersonate(page, NZAP_TEST_ID);
const newArt = new NewArtPage(page);
await newArt.goto();
await newArt.selectImage(TEST_IMAGE_PATH);
await expect(newArt.locators.preview).toBeVisible();
const userArt = await newArt.save();
await expect(page).toHaveURL(/\/u\/.*\/art/);
await expect(userArt.locators.pendingApprovalText).toBeVisible();
await impersonate(page);
const imageValidation = new ImageValidationPage(page);
await imageValidation.goto();
await expect(imageValidation.locators.images.first()).toBeVisible();
await imageValidation.approveAll();
await expect(imageValidation.locators.allValidatedText).toBeVisible();
await userArt.goto(NZAP_TEST_DISCORD_ID);
const artImage = userArt.image(0);
await expect(artImage).toBeVisible();
const box = await artImage.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
test("edits already uploaded art keeping its image", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(NZAP_TEST_ID, { roles: ["ARTIST"] });
const art = await factories.ArtFactory.create({ authorId: NZAP_TEST_ID });
await impersonate(page, NZAP_TEST_ID);
const userArt = new UserArtPage(page);
await userArt.goto(NZAP_TEST_DISCORD_ID);
await userArt.editLink(art.id).click();
const newArt = new NewArtPage(page);
// the already uploaded image is shown but can't be swapped
// only rendering is asserted as the factory made art has no image file
await expect(newArt.locators.existingImage).toBeAttached();
await expect(newArt.locators.fileInput).toHaveCount(0);
await newArt.form.fill("description", "Squid drawing");
await newArt.save();
await expect(page).toHaveURL(/\/u\/.*\/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");
});
});

View File

@@ -0,0 +1,66 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import {
expect,
impersonate,
isNotVisible,
navigate,
test,
} from "./helpers/playwright";
import { AssociationsPage } from "./pages/associations/associations-page";
import { NewAssociationPage } from "./pages/associations/new-association-page";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { ScrimsPage } from "./pages/scrims/scrims-page";
test.describe("Associations", () => {
test("creates a new association", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await new AnythingAdder(page).add("association");
const newAssociation = new NewAssociationPage(page);
await newAssociation.form.fill("name", "My Association");
await newAssociation.save();
const associations = new AssociationsPage(page);
await expect(associations.heading("My Association")).toBeVisible();
});
test("deletes an association", async ({ page, factories }) => {
await factories.AssociationFactory.create({ userId: ADMIN_ID });
await factories.AssociationFactory.create({ userId: ADMIN_ID });
await impersonate(page, ADMIN_ID);
const scrims = new ScrimsPage(page);
await scrims.goto();
const associations = await scrims.openAssociations();
await expect(associations.locators.deleteButtons).toHaveCount(2);
await associations.deleteFirst();
await expect(associations.locators.deleteButtons).toHaveCount(1);
});
test("joins and leaves an association", async ({ page, factories }) => {
await factories.AssociationFactory.create({ userId: ADMIN_ID });
await impersonate(page, ADMIN_ID);
const associations = new AssociationsPage(page);
await associations.goto();
const inviteCode = await associations.inviteCode();
await impersonate(page, NZAP_TEST_ID);
await associations.gotoInvite(inviteCode);
await associations.join();
await associations.leave();
await isNotVisible(associations.locators.leaveButton);
});
});

View File

@@ -0,0 +1,53 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import { badgePage } from "~/utils/urls";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { BadgePage } from "./pages/badges/badge-page";
import { NotificationPopover } from "./pages/layout/notification-popover";
const BADGE_NAME = "4v4 Sundaes";
test.describe("Badges", () => {
test("adds a badge owner sending a notification", async ({
page,
factories,
}) => {
const badge = await factories.BadgeFactory.create(
{ displayName: BADGE_NAME },
{ managerIds: [NZAP_TEST_ID] },
);
// the popover links onwards only once its peek list is full
for (let seasonNth = 1; seasonNth < NOTIFICATIONS.PEEK_COUNT; seasonNth++) {
await factories.NotificationFactory.create({
notification: { type: "SEASON_STARTED", meta: { seasonNth } },
users: [{ userId: ADMIN_ID, seen: 1 }],
});
}
await impersonate(page, NZAP_TEST_ID);
const badgeDetails = new BadgePage(page);
await badgeDetails.goto(badge.id);
const badgeEdit = await badgeDetails.openEdit();
await badgeEdit.addOwner("Sendou");
await badgeEdit.save();
await expect(badgeDetails.owner("Sendou")).toBeVisible();
await impersonate(page);
await navigate({ page, url: "/" });
const notifications = new NotificationPopover(page);
await notifications.open();
await notifications.openNotification(`New badge (${BADGE_NAME})`);
await expect(page).toHaveURL(badgePage(badge.id));
await notifications.open();
const allNotifications = await notifications.openAll();
await expect(allNotifications.locators.heading).toBeVisible();
});
});

View File

@@ -0,0 +1,68 @@
import { addDays, setHours, startOfHour } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { SUSPENDED_PAGE } from "~/utils/urls";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { AdminBanPage } from "./pages/ban/admin-ban-page";
import { SuspendedPage } from "./pages/ban/suspended-page";
test.describe("User banning", () => {
test("banned user is redirected to suspended page and cannot access site", async ({
page,
}) => {
await impersonate(page, ADMIN_ID);
const adminBan = new AdminBanPage(page);
await adminBan.goto();
await adminBan.banUser("N-ZAP", { reason: "Test ban reason" });
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const suspended = new SuspendedPage(page);
await expect(page).toHaveURL(SUSPENDED_PAGE);
await expect(suspended.locators.heading).toBeVisible();
await expect(suspended.reason("Test ban reason")).toBeVisible();
await expect(suspended.locators.noEndTime).toBeVisible();
await navigate({ page, url: "/builds" });
await expect(page).toHaveURL(SUSPENDED_PAGE);
await navigate({ page, url: "/calendar" });
await expect(page).toHaveURL(SUSPENDED_PAGE);
await impersonate(page, ADMIN_ID);
await adminBan.goto();
await adminBan.unbanUser("N-ZAP");
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await expect(page).not.toHaveURL(SUSPENDED_PAGE);
});
test("timed ban shows expiration date on suspended page", async ({
page,
}) => {
await impersonate(page, ADMIN_ID);
const adminBan = new AdminBanPage(page);
await adminBan.goto();
await adminBan.banUser("N-ZAP", {
expiresAt: startOfHour(setHours(addDays(new Date(), 1), 12)),
reason: "Temporary ban",
});
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const suspended = new SuspendedPage(page);
await expect(page).toHaveURL(SUSPENDED_PAGE);
await expect(suspended.locators.heading).toBeVisible();
await expect(suspended.reason("Temporary ban")).toBeVisible();
await expect(suspended.locators.noEndTime).not.toBeVisible();
await expect(suspended.locators.endsAt).toBeVisible();
// the time based expiration logic itself is covered by app/features/ban/core/banned.test.ts
});
});

View File

@@ -0,0 +1,132 @@
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 { BuildFormPage } from "./pages/builds/build-form-page";
import { BuildsPage } from "./pages/builds/builds-page";
import { UserBuildsPage } from "./pages/builds/user-builds-page";
import { WeaponBuildsPage } from "./pages/builds/weapon-builds-page";
const ABILITIES_WITH_ISM: BuildAbilitiesTuple = [
["ISM", "ISM", "ISM", "ISM"],
["SSU", "SSU", "SSU", "SSU"],
["RSU", "RSU", "RSU", "RSU"],
];
const ABILITIES_WITHOUT_ISM: BuildAbilitiesTuple = [
["SSU", "SSU", "SSU", "SSU"],
["RSU", "RSU", "RSU", "RSU"],
["QR", "QR", "QR", "QR"],
];
test.describe("Builds", () => {
test("adds a build", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
const buildForm = new BuildFormPage(page);
await buildForm.gotoNew(NZAP_TEST_DISCORD_ID);
await buildForm.form.selectWeapons("weapons", [
"Tenta Brella",
"Splat Brella",
]);
await buildForm.selectGear("HEAD", "White Headband");
await buildForm.selectGear("CLOTHES", "Basic Tee");
await buildForm.selectGear("SHOES", "Blue Lo-Tops");
await buildForm.addAbility("ISM", 12);
await buildForm.form.fill("title", "Test Build");
await buildForm.form.fill("description", "Test Description");
await buildForm.form.checkItems("modes", ["TC"]);
await buildForm.form.submit();
const userBuilds = new UserBuildsPage(page);
await expect(userBuilds.locators.changeSortingButton).toBeVisible();
const firstBuildCard = userBuilds.buildCard(0);
await expect(firstBuildCard.weaponImage("Tenta Brella")).toBeVisible();
await expect(firstBuildCard.weaponImage("Splat Brella")).toBeVisible();
await expect(firstBuildCard.modeImage("Tower Control")).toBeVisible();
await expect(firstBuildCard.modeImage("Splat Zones")).not.toBeVisible();
await expect(firstBuildCard.title).toContainText("Test Build");
});
test("makes build private", async ({ page, factories }) => {
// backdating one build makes the updatedAt sort deterministic
const [olderBuild] = await factories.BuildFactory.createMany(2, {
ownerId: ADMIN_ID,
});
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);
const buildIdBefore = await userBuilds.buildId(0);
const buildForm = await userBuilds.editBuild(0);
await buildForm.form.check("isPrivate");
await buildForm.form.submit();
await expect(userBuilds.locators.buildsTab).toContainText("Builds (2)");
await expect(userBuilds.buildCard(0).root).toContainText("Private");
const buildIdAfter = await userBuilds.buildId(0);
expect(buildIdAfter).toBe(buildIdBefore);
await impersonate(page, NZAP_TEST_ID);
await userBuilds.goto(ADMIN_DISCORD_ID);
await expect(userBuilds.locators.buildsTab).toContainText("Builds (1)");
await expect(userBuilds.buildCard(0).root).not.toContainText("Private");
});
test("filters builds", async ({ page, factories }) => {
await factories.BuildFactory.createMany(3, {
ownerId: ADMIN_ID,
weaponSplIds: [40],
modes: ["TC"],
abilities: ABILITIES_WITH_ISM,
});
await factories.BuildFactory.createMany(2, {
ownerId: ADMIN_ID,
weaponSplIds: [40],
modes: ["SZ"],
abilities: ABILITIES_WITHOUT_ISM,
});
const weaponBuilds = new WeaponBuildsPage(page);
await new BuildsPage(page).openWeapon(40);
await weaponBuilds.addFilter("ability");
await weaponBuilds.locators.comparisonSelect.selectOption("AT_MOST");
// are all builds with ISM are hidden?
await expect(weaponBuilds.ability("ISM")).toHaveCount(1);
await weaponBuilds.deleteFilter("ability");
await expect(weaponBuilds.ability("ISM").nth(1)).toBeVisible();
await weaponBuilds.addFilter("mode");
await weaponBuilds.modeFilterCheckbox("Tower Control").click();
await expect(weaponBuilds.modeBadge("TC")).toHaveCount(3);
await weaponBuilds.deleteFilter("mode");
await expect(weaponBuilds.locators.buildCards.first()).toBeVisible();
await weaponBuilds.addFilter("date");
await weaponBuilds.locators.dateSelect.selectOption("CUSTOM");
await expect(weaponBuilds.locators.dateInput).toBeVisible();
// no change in count since all builds in test data are new
await expect(weaponBuilds.locators.buildCards).toHaveCount(5);
});
});

View File

@@ -0,0 +1,278 @@
import { subDays } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { calendarEventPage } from "~/utils/urls";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { CalendarEventPage } from "./pages/calendar/calendar-event-page";
import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
import { ReportWinnersPage } from "./pages/calendar/report-winners-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentInfoPage } from "./pages/tournament/tournament-info-page";
import { TournamentRulesPage } from "./pages/tournament/tournament-rules-page";
const SENDOU_INK_TOURNAMENTS_COUNT = 3;
const EXTERNAL_EVENTS_COUNT = 2;
test.describe("Calendar", () => {
test("applies filters and operates hidden events toggle", async ({
page,
factories,
}) => {
// all of them at the same time so they share one clock header and its toggle
const startTimes = [dateToDatabaseTimestamp(new Date())];
for (let i = 0; i < SENDOU_INK_TOURNAMENTS_COUNT; i++) {
await factories.TournamentFactory.create({
authorId: ADMIN_ID,
startTimes,
});
}
for (let i = 0; i < EXTERNAL_EVENTS_COUNT; i++) {
await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
startTimes,
});
}
const calendar = new CalendarPage(page);
await calendar.goto();
await calendar.toggleEventTypeFilter("isSendou");
await expect(calendar.locators.tournamentCards).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT,
);
await calendar.reload();
// remembers selection via search params
await expect(calendar.locators.tournamentCards).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT,
);
await calendar.toggleHiddenEvents();
await expect(calendar.locators.tournamentCards).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT + EXTERNAL_EVENTS_COUNT,
);
await calendar.toggleHiddenEvents();
await expect(calendar.locators.tournamentCards).toHaveCount(
SENDOU_INK_TOURNAMENTS_COUNT,
);
});
test("sets default filters", async ({ page, factories }) => {
// not hosted on sendou.ink, so the ranked filter hides it
await factories.CalendarEventFactory.create({ authorId: ADMIN_ID });
await impersonate(page, NZAP_TEST_ID);
const calendar = new CalendarPage(page);
await calendar.goto();
await isNotVisible(calendar.locators.hiddenEventsButtons);
await calendar.toggleEventTypeFilter("isRanked");
await calendar.saveFiltersAsDefault();
await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible();
await calendar.goto();
// remembers selection via user preferences
await expect(calendar.locators.hiddenEventsButtons.first()).toBeVisible();
await calendar.removeEventTypeFilter();
// removing the filter sticks instead of falling back to the saved default
await isNotVisible(calendar.locators.hiddenEventsButtons);
await calendar.reload();
await isNotVisible(calendar.locators.hiddenEventsButtons);
});
test("navigates view more buttons", async ({ page }) => {
const calendar = new CalendarPage(page);
await calendar.goto();
await calendar.navigatePrevious();
await isNotVisible(calendar.locators.todayHeader);
await calendar.navigateNext();
await expect(calendar.locators.todayHeader).toBeVisible();
});
test("renders clock header times in the browser locale", async ({
browser,
workerBaseURL,
factories,
}) => {
await factories.CalendarEventFactory.create({ authorId: ADMIN_ID });
const openWith = async (locale: string) => {
const context = await browser.newContext({
locale,
baseURL: workerBaseURL,
});
const page = await context.newPage();
return { context, page };
};
const ca = await openWith("en-CA");
const gb = await openWith("en-GB");
try {
const caCalendar = new CalendarPage(ca.page);
const gbCalendar = new CalendarPage(gb.page);
await caCalendar.goto();
await gbCalendar.goto();
const caTime = await caCalendar.locators.clockHeaderTimes
.first()
.textContent();
const gbTime = await gbCalendar.locators.clockHeaderTimes
.first()
.textContent();
expect(caTime).toMatch(/AM|PM|a\.m\.|p\.m\./i);
expect(gbTime).not.toMatch(/AM|PM|a\.m\.|p\.m\./i);
expect(caTime).not.toBe(gbTime);
} finally {
await ca.context.close();
await gb.context.close();
}
});
test("creates a new calendar event", async ({ page }) => {
await impersonate(page);
const newEvent = new CalendarNewEventPage(page);
await newEvent.goto();
await newEvent.form.fill("name", "Test Calendar Event");
await newEvent.setFirstDate(new Date(2027, 0, 15, 17, 0));
await newEvent.form.fill("bracketUrl", "https://sendou.ink/test-bracket");
await newEvent.form.submit();
await expect(page).toHaveURL(/\/calendar\/\d+/);
});
test("creates a new tournament with a map pool and follow-up bracket", async ({
page,
factories,
}) => {
// tournaments can only be added by an organizer, unlike calendar events
const organizer = await factories.UserFactory.create(null, {
roles: ["TOURNAMENT_ORGANIZER"],
});
await impersonate(page, organizer.id);
const newTournament = new CalendarNewEventPage(page);
await newTournament.gotoNewTournament();
const startTime = new Date(2027, 0, 15, 17, 0);
const mapPool = [
{ stage: "Scorch Gorge", mode: "Splat Zones" },
{ stage: "Eeltail Alley", mode: "Tower Control" },
{ stage: "Hagglefish Market", mode: "Rainmaker" },
];
await newTournament.form.fill("name", "Test Tournament");
await newTournament.form.fill(
"description",
"An automated test tournament",
);
await newTournament.setFirstDate(startTime);
await newTournament.form.fill("discordInviteCode", "test-invite");
// flip a tournament setting away from its default
await newTournament.form.check("requireInGameNames");
// "Picked by TO" allows an arbitrary map pool, unlike the validated tiebreaker modes
await newTournament.form.select("toToolsMode", "TO");
await newTournament.pickMapPool(mapPool);
await newTournament.addFollowUpBracket({
name: "Underground bracket",
format: "Single elimination",
placements: "-1",
});
await newTournament.form.submit();
await expect(page).toHaveURL(/\/to\/\d+/);
const tournamentId = Number(page.url().match(/\/to\/(\d+)/)?.[1]);
// start time round-trips
const tournamentInfo = new TournamentInfoPage(page);
await tournamentInfo.goto(tournamentId);
await expect(tournamentInfo.startTime(startTime).first()).toBeVisible();
// the follow-up bracket shows up
const brackets = new TournamentBracketsPage(page);
await brackets.goto(tournamentId);
await expect(brackets.bracketTab("Underground")).toBeVisible();
// the TO map pool round-trips onto the rules page
const rules = new TournamentRulesPage(page);
await rules.goto(tournamentId);
for (const { stage, mode } of mapPool) {
await expect(rules.stageName(stage)).toBeVisible();
await expect(rules.modeImage(mode).first()).toBeVisible();
}
});
test("reports winners of a past event", async ({ page, factories }) => {
const event = await factories.CalendarEventFactory.create({
authorId: ADMIN_ID,
startTimes: [dateToDatabaseTimestamp(subDays(new Date(), 1))],
});
await impersonate(page);
const reportWinners = new ReportWinnersPage(page);
await reportWinners.goto(event.id);
await reportWinners.locators.participantCountInput.fill("50");
await reportWinners.locators.teamNameInput.fill("Team Olive");
await reportWinners.locators.placingInput.fill("1");
// a team without any players can't be reported
await reportWinners.locators.submitButton.click();
await expect(reportWinners.locators.emptyTeamError).toBeVisible();
await reportWinners.selectPlayer(1, "N-ZAP");
await reportWinners.fillPlayerAsText(2, "Player Without Account");
await reportWinners.submit();
await expect(page).toHaveURL(calendarEventPage(event.id));
const calendarEvent = new CalendarEventPage(page);
await expect(calendarEvent.resultRow("Team Olive")).toContainText(
"Player Without Account",
);
// reported results are loaded back into the form for editing
await reportWinners.goto(event.id);
await expect(reportWinners.locators.participantCountInput).toHaveValue(
"50",
);
await expect(reportWinners.locators.teamNameInput).toHaveValue(
"Team Olive",
);
await expect(reportWinners.locators.placingInput).toHaveValue("1");
await expect(reportWinners.player(1)).toContainText("N-ZAP");
await expect(reportWinners.player(2)).toHaveValue("Player Without Account");
});
});

View File

@@ -0,0 +1,166 @@
import { expect, test } from "./helpers/playwright";
import { CompAnalyzerPage } from "./pages/comp-analyzer/comp-analyzer-page";
test.describe("Composition Analyzer", () => {
test("weapon selection, removal, and URL persistence", async ({ page }) => {
const compAnalyzer = new CompAnalyzerPage(page);
await compAnalyzer.goto();
await expect(compAnalyzer.locators.selectedWeapons).toBeVisible();
await expect(compAnalyzer.locators.emptyWeaponSlots).toHaveCount(4);
await compAnalyzer.selectWeapon(40);
// First slot should now have the weapon
await expect(compAnalyzer.selectedWeapon(0)).toBeVisible();
await expect(compAnalyzer.locators.emptyWeaponSlots).toHaveCount(3);
await compAnalyzer.removeWeapon(0);
await expect(compAnalyzer.locators.emptyWeaponSlots).toHaveCount(4);
// Select 4 weapons to test auto-collapse
await expect(compAnalyzer.locators.categorizationToggle).toBeVisible();
await compAnalyzer.selectWeapon(40);
await compAnalyzer.selectWeapon(50);
await compAnalyzer.selectWeapon(60);
await compAnalyzer.selectWeapon(70);
await expect(compAnalyzer.locators.categorizationToggle).not.toBeVisible();
await expect(page).toHaveURL(/weapons=/);
// Weapons should still be selected after reload
await page.reload();
await expect(compAnalyzer.selectedWeapon(0)).toBeVisible();
await expect(compAnalyzer.selectedWeapon(1)).toBeVisible();
await expect(compAnalyzer.selectedWeapon(2)).toBeVisible();
await expect(compAnalyzer.selectedWeapon(3)).toBeVisible();
});
test("weapon grid controls and categorization", async ({ page }) => {
const compAnalyzer = new CompAnalyzerPage(page);
await compAnalyzer.goto();
await expect(compAnalyzer.categorizationRadio("category")).toBeChecked();
await compAnalyzer.selectCategorization("sub");
await expect(compAnalyzer.categorizationRadio("sub")).toBeChecked();
await compAnalyzer.selectCategorization("special");
await expect(compAnalyzer.categorizationRadio("special")).toBeChecked();
// Grid should be expanded
await expect(compAnalyzer.locators.categorizationToggle).toBeVisible();
await compAnalyzer.toggleWeaponGrid();
await expect(compAnalyzer.locators.categorizationToggle).not.toBeVisible();
await compAnalyzer.toggleWeaponGrid();
await expect(compAnalyzer.locators.categorizationToggle).toBeVisible();
// Switch categorization and test URL persistence
await compAnalyzer.selectCategorization("sub");
await expect(page).toHaveURL(/categorization=sub/);
await page.reload();
await expect(compAnalyzer.categorizationRadio("sub")).toBeChecked();
});
test("analysis sections appear and can be collapsed", async ({ page }) => {
const compAnalyzer = new CompAnalyzerPage(page);
await compAnalyzer.goto();
const damageCombos = compAnalyzer.damageCombos;
// Both should not be visible initially
await expect(damageCombos.root).not.toBeVisible();
await expect(compAnalyzer.locators.rangeVisualization).not.toBeVisible();
// Select two weapons with range data (blaster - ID 210)
await compAnalyzer.selectWeapon(40);
await compAnalyzer.selectWeapon(210);
await expect(damageCombos.root).toBeVisible();
await expect(compAnalyzer.locators.rangeVisualization).toBeVisible();
// Should be expanded by default
await expect(damageCombos.content).toBeVisible();
await damageCombos.toggleCollapsed();
await expect(damageCombos.content).not.toBeVisible();
await damageCombos.toggleCollapsed();
await expect(damageCombos.content).toBeVisible();
});
test("damage combo sliders and filtering work correctly", async ({
page,
}) => {
const compAnalyzer = new CompAnalyzerPage(page);
await compAnalyzer.goto();
// Splattershot Jr. has Splat Bomb sub
await compAnalyzer.selectWeapon(10);
await compAnalyzer.selectWeapon(40);
const damageCombos = compAnalyzer.damageCombos;
await expect(damageCombos.root).toBeVisible();
// Part 1: Test Sub Defense slider
const initialDamageValues =
await damageCombos.damageValues.allTextContents();
await damageCombos.subDefenseSlider.fill("57");
// Damage of sub weapons should be reduced
const newDamageValues = await damageCombos.damageValues.allTextContents();
expect(initialDamageValues.join(",")).not.toEqual(
newDamageValues.join(","),
);
await damageCombos.subDefenseSlider.fill("0");
// Part 2: Test Ink Resistance slider
const initialInkTimes = await damageCombos.inkTimes.allTextContents();
await damageCombos.inkResistanceSlider.fill("57");
const newInkTimes = await damageCombos.inkTimes.allTextContents();
// If there were ink times, they should have increased or more ink combos should appear
if (initialInkTimes.length > 0 || newInkTimes.length > 0) {
const initialTotalFrames = initialInkTimes.reduce(
(sum, t) => sum + (Number.parseInt(t, 10) || 0),
0,
);
const newTotalFrames = newInkTimes.reduce(
(sum, t) => sum + (Number.parseInt(t, 10) || 0),
0,
);
expect(newTotalFrames).toBeGreaterThanOrEqual(initialTotalFrames);
}
// Part 3: Test damage type filtering
const firstDamageTypeLabel = damageCombos.damageTypeLabels.first();
const damageTypeText = await firstDamageTypeLabel.textContent();
await expect(damageCombos.filteredItems).toHaveCount(0);
await firstDamageTypeLabel.click();
const filteredCount = await damageCombos.filteredItems.count();
expect(filteredCount).toBeGreaterThan(0);
await expect(damageCombos.filteredItems.first()).toContainText(
damageTypeText ?? "",
);
// Clicking a filtered item restores it
await damageCombos.filteredItems.first().click();
await expect(damageCombos.filteredItems).toHaveCount(filteredCount - 1);
});
});

View File

@@ -0,0 +1,60 @@
import { addHours } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { expect, impersonate, test } from "./helpers/playwright";
import { EventsPage } from "./pages/calendar/events-page";
const JOINED_TOURNAMENT_NAME = "Joined Tournament";
const ORGANIZED_TOURNAMENT_NAME = "Organized Tournament";
test.describe("Events", () => {
test("filters between tabs and navigates to an event", async ({
page,
factories,
}) => {
const startsAt = dateToDatabaseTimestamp(addHours(new Date(), 2));
const joinedTournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: JOINED_TOURNAMENT_NAME,
startTimes: [startsAt],
});
await factories.TournamentTeamFactory.create({
tournamentId: joinedTournament.id,
memberUserIds: [NZAP_TEST_ID],
});
await factories.TournamentFactory.create({
authorId: NZAP_TEST_ID,
name: ORGANIZED_TOURNAMENT_NAME,
startTimes: [startsAt],
});
await factories.ScrimPostFactory.create({
startsAt,
users: [{ userId: NZAP_TEST_ID, isOwner: 1 }],
});
await impersonate(page, NZAP_TEST_ID);
const events = new EventsPage(page);
await events.goto();
await expect(events.locators.title).toBeVisible();
// the first non-empty category is shown by default
await expect(events.eventLink(JOINED_TOURNAMENT_NAME)).toBeVisible();
await events.openView("scrims");
await expect(events.eventLink("Looking for scrim")).toBeVisible();
await events.openView("saved");
await expect(events.locators.emptyCategoryText).toBeVisible();
await events.openView("hosting");
const hostedEvent = events.eventLink(ORGANIZED_TOURNAMENT_NAME);
await expect(hostedEvent).toBeVisible();
await hostedEvent.click();
await expect(page).not.toHaveURL(/\/events/);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

View File

@@ -0,0 +1,35 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { FriendsPage } from "./pages/friends/friends-page";
import { NotificationPopover } from "./pages/layout/notification-popover";
test.describe("Friends", () => {
test("send friend request, accept it, then delete friend", async ({
page,
}) => {
const friends = new FriendsPage(page);
await impersonate(page);
await friends.goto();
await friends.sendRequest("N-ZAP");
await expect(friends.locators.cancelRequestButton).toBeVisible();
await impersonate(page, NZAP_TEST_ID);
await friends.goto();
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await expect(friends.locators.acceptButton).toBeVisible();
await friends.acceptRequest();
// accepting resolved the friend request notification without the bell
// having been opened
await expect(notifications.locators.bellDot).toBeHidden();
await friends.friend("Sendou").deleteFriend();
await expect(friends.locators.noFriendsText).toBeVisible();
});
});

View File

@@ -0,0 +1,49 @@
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls";
import { userPage } from "~/utils/urls";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { GlobalSearchDialog } from "./pages/search/global-search-dialog";
const ORGANIZATION_NAME = "Sendou.ink";
test.describe("Global search", () => {
test("searches for users and organizations", async ({ page, factories }) => {
const org = await factories.TournamentOrganizationFactory.create({
ownerId: ADMIN_ID,
name: ORGANIZATION_NAME,
});
await impersonate(page);
await navigate({ page, url: "/" });
const search = new GlobalSearchDialog(page);
await search.open();
await search.selectType("users");
await search.search("sendou");
await search.selectOption("Sendou");
await expect(page).toHaveURL(userPage({ discordId: ADMIN_DISCORD_ID }));
await search.open();
await search.selectType("organizations");
await search.search("sendou");
await search.selectOption(ORGANIZATION_NAME);
await expect(page).toHaveURL(
tournamentOrganizationPage({ organizationSlug: org.slug }),
);
});
test("searches for weapons", async ({ page }) => {
await impersonate(page);
await navigate({ page, url: "/" });
const search = new GlobalSearchDialog(page);
await search.open();
await search.selectType("weapons");
await search.search("splattershot");
await search.selectOption("Splattershot");
await search.selectOption("Builds");
await expect(page).toHaveURL(/\/builds\/splattershot/);
});
});

View File

@@ -0,0 +1,289 @@
import { type ChildProcess, execSync, spawn } from "node:child_process";
import fs from "node:fs";
import type { FullConfig } from "@playwright/test";
import { ensureMigratedDb } from "../scripts/ensure-test-db";
import {
E2E_BASE_PORT,
e2eWebhookPort,
e2eWorkerPort,
} from "./helpers/playwright";
const DEBUG = process.env.E2E_DEBUG === "true";
const SERVER_PROCESSES: ChildProcess[] = [];
const MINIO_MARKER_FILE = ".e2e-minio-started";
const STORAGE_BUCKET = "sendou";
/** Anonymously listable only once the bucket exists and its public policy is set. */
const MINIO_BUCKET_URL = `http://127.0.0.1:9000/${STORAGE_BUCKET}/`;
const BUILD_MARKER_FILE = ".e2e-build-marker";
const BUILD_INPUTS = [
"app",
"public",
"package.json",
"../../pnpm-lock.yaml",
"vite.config.ts",
"react-router.config.ts",
];
declare global {
var __E2E_SERVERS__: ChildProcess[];
}
/**
* Whether the image storage is usable, which takes more than MinIO answering its health check:
* the container bootstraps the bucket only after startup, and a run whose bucket never got created
* would otherwise fail deep inside the one test that uploads an image (`art.spec.ts`) with an
* opaque 500.
*/
async function isMinioBucketReady(): Promise<boolean> {
try {
const response = await fetch(MINIO_BUCKET_URL);
return response.ok;
} catch {
return false;
}
}
async function waitForMinio(timeout = 60000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await isMinioBucketReady()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
async function ensureMinioRunning(): Promise<boolean> {
// Check if MinIO is already running
if (await isMinioBucketReady()) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("MinIO is already running");
return false;
}
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Starting MinIO...");
execSync("docker compose up -d minio", { stdio: "inherit" });
const isReady = await waitForMinio();
if (!isReady) {
throw new Error(
`MinIO did not become usable within timeout (${MINIO_BUCKET_URL} never answered OK). If MinIO is running, its "${STORAGE_BUCKET}" bucket is missing or not public — recreate the container with "docker compose up -d --force-recreate minio".`,
);
}
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("MinIO is ready");
fs.writeFileSync(MINIO_MARKER_FILE, "");
return true;
}
/** Kills anything listening on the port range, returning whether something was killed. */
function killProcessesOnPorts(firstPort: number, lastPort: number): boolean {
try {
const pids = execSync(`lsof -ti :${firstPort}-${lastPort} || true`, {
stdio: "pipe",
})
.toString()
.trim();
if (pids === "") return false;
execSync(`kill -9 ${pids.split("\n").join(" ")} 2>/dev/null || true`, {
stdio: "pipe",
});
return true;
} catch {
return false;
}
}
async function waitForServer(port: number, timeout = 120000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const response = await fetch(`http://localhost:${port}/`);
if (response.ok || response.status === 404) {
// 404 is fine - server is up, just no route at /
return;
}
} catch {
// Server not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Server on port ${port} did not start within ${timeout}ms`);
}
/**
* The last e2e build can be reused when no build input changed since its marker
* was written. Directory mtimes catch deletes and renames; a build made outside
* the e2e flow (no marker, or output newer than the marker) forces a rebuild, as
* does a marker from a different port setup. E2E_FORCE_BUILD=true overrides.
*/
function isBuildFresh(): boolean {
if (process.env.E2E_FORCE_BUILD === "true") return false;
if (!fs.existsSync(BUILD_MARKER_FILE)) return false;
if (!fs.existsSync("build/server/index.js")) return false;
try {
const marker = JSON.parse(fs.readFileSync(BUILD_MARKER_FILE, "utf8"));
if (marker.siteDomain !== `http://localhost:${E2E_BASE_PORT}`) return false;
if (marker.skalopWsUrl !== "") return false;
} catch {
return false;
}
const markerMtime = fs.statSync(BUILD_MARKER_FILE).mtimeMs;
if (fs.statSync("build/server/index.js").mtimeMs > markerMtime) return false;
try {
const changedInput = execSync(
`find ${BUILD_INPUTS.join(" ")} -newer ${BUILD_MARKER_FILE} -print -quit`,
)
.toString()
.trim();
return changedInput === "";
} catch {
return false;
}
}
async function globalSetup(config: FullConfig) {
const workerCount = config.workers;
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`\nStarting e2e test setup with ${workerCount} workers...`);
// Start MinIO if not already running
await ensureMinioRunning();
// Build the app once with E2E test flag so VITE_E2E_TEST_RUN is embedded
// Use port 6173 as the base - tests will rewrite URLs as needed
if (isBuildFresh()) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(
"Reusing existing build (no source changes since last e2e build)",
);
} else {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Building the application...");
fs.rmSync(BUILD_MARKER_FILE, { force: true });
execSync("pnpm run build", {
stdio: "inherit",
env: {
...process.env,
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: `http://localhost:${E2E_BASE_PORT}`,
// Skalop is disconnected in e2e: all workers sharing one instance
// cross-talk (identical seeded row ids -> colliding room names ->
// spurious revalidations). When e2e tests for chat etc. are added
// this needs an actual solution: one skalop (or stub) per worker
// with a runtime-derived ws URL, since this build is shared.
VITE_SKALOP_WS_URL: "",
},
});
fs.writeFileSync(
BUILD_MARKER_FILE,
JSON.stringify({
siteDomain: `http://localhost:${E2E_BASE_PORT}`,
skalopWsUrl: "",
}),
);
}
// Prepare databases and start servers for each worker
const serverPromises: Promise<void>[] = [];
// Kill any existing processes on our ports before starting; sweep beyond the
// current worker count so leftovers from a run with more workers also die
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Cleaning up any existing processes on e2e ports...");
const killedSomething = killProcessesOnPorts(
E2E_BASE_PORT,
e2eWorkerPort(Math.max(workerCount, 8) - 1),
);
if (killedSomething) {
// Wait briefly for ports to be released
await new Promise((resolve) => setTimeout(resolve, 500));
}
for (let i = 0; i < workerCount; i++) {
const port = e2eWorkerPort(i);
const dbPath = `db-test-e2e-${i}.sqlite3`;
ensureMigratedDb(dbPath);
// Start server
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Starting server for worker ${i} on port ${port}...`);
// react-router-serve directly instead of `pnpm start`: ensureMigratedDb
// above already migrated, making the script's `migrate up` step redundant
const serverProcess = spawn(
process.execPath,
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
{
env: {
...process.env,
NODE_ENV: "production",
DB_PATH: dbPath,
PORT: String(port),
DISCORD_CLIENT_ID: "123",
DISCORD_CLIENT_SECRET: "secret",
SESSION_SECRET: "secret",
VITE_SITE_DOMAIN: `http://localhost:${port}`,
VITE_E2E_TEST_RUN: "true",
STORAGE_END_POINT: "http://127.0.0.1:9000",
STORAGE_ACCESS_KEY: "minio-user",
STORAGE_SECRET: "minio-password",
STORAGE_REGION: "us-east-1",
STORAGE_BUCKET,
// no system messages to a shared skalop instance (see build env above)
SKALOP_SYSTEM_MESSAGE_URL: "",
SKALOP_TOKEN: "",
// creds from .env must not reach test servers (SyncLiveStreams would
// hit the real Twitch API and overwrite factory-seeded streams)
TWITCH_CLIENT_ID: "",
TWITCH_CLIENT_SECRET: "",
// tests assert webhook payloads by listening on the worker's webhook port
SQ_CANCEL_DISCORD_WEBHOOK_URL: `http://localhost:${e2eWebhookPort(i)}/sq-cancel`,
},
detached: false,
},
);
SERVER_PROCESSES.push(serverProcess);
if (DEBUG) {
serverProcess.stdout?.on("data", (data) => {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`[Worker ${i}] ${data.toString()}`);
});
serverProcess.stderr?.on("data", (data) => {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.error(`[Worker ${i} ERROR] ${data.toString()}`);
});
}
serverPromises.push(
waitForServer(port).then(() => {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Server for worker ${i} is ready on port ${port}`);
}),
);
}
// Store server processes globally for teardown before awaiting readiness so
// a failed startup still gets every already-spawned server cleaned up
global.__E2E_SERVERS__ = SERVER_PROCESSES;
// Wait for all servers to be ready
await Promise.all(serverPromises);
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("\nAll servers started successfully!\n");
}
export default globalSetup;

View File

@@ -0,0 +1,59 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import type { FullConfig } from "@playwright/test";
const MINIO_MARKER_FILE = ".e2e-minio-started";
declare global {
var __E2E_SERVERS__: import("node:child_process").ChildProcess[];
}
async function globalTeardown(_config: FullConfig) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("\nStopping e2e test servers...");
const servers = global.__E2E_SERVERS__ || [];
const exits: Promise<void>[] = [];
for (const server of servers) {
if (server && !server.killed && server.exitCode === null) {
exits.push(
new Promise((resolve) => server.once("exit", () => resolve())),
);
if (process.platform === "win32" && server.pid) {
// the whole process tree needs to be killed on Windows to not leave
// the ports occupied
try {
execSync(`taskkill /pid ${server.pid} /T /F`, { stdio: "pipe" });
} catch {
// Ignore errors, process might already be gone
}
} else {
server.kill("SIGTERM");
}
}
}
await Promise.race([
Promise.all(exits),
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
// Stop MinIO if we started it (check for marker file)
if (fs.existsSync(MINIO_MARKER_FILE)) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Stopping MinIO...");
try {
execSync("docker compose stop minio", { stdio: "inherit" });
} catch {
// Ignore errors - MinIO might already be stopped
}
fs.unlinkSync(MINIO_MARKER_FILE);
}
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("All servers stopped.\n");
}
export default globalTeardown;

View File

@@ -0,0 +1,45 @@
import type { Page } from "@playwright/test";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */
const FIRST_COUNTERPICK_STAGE_ID = 5;
/** A stage of a `ModeMapPoolPicker`, whether picked or not. */
export function counterpickMap(page: Page, mode: ModeShort, stageId: StageId) {
return page.getByTestId(`map-pool-${mode}-${stageId}`);
}
/** The check overlaying a picked stage. It covers the stage itself, so unpicking goes through it. */
export function pickedCounterpickMap(
page: Page,
mode: ModeShort,
stageId: StageId,
) {
return page.getByTestId(`map-pool-${mode}-${stageId}-picked`);
}
/**
* Picks the required amount of counterpick maps for every ranked mode, skipping
* banned ones, and returns what was picked.
*/
export async function pickCounterpickMaps(page: Page) {
const picked: Array<{ mode: ModeShort; stageId: StageId }> = [];
let stageId = FIRST_COUNTERPICK_STAGE_ID;
for (const mode of rankedModesShort) {
for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) {
while (BANNED_MAPS[mode].includes(stageId as StageId)) {
stageId++;
}
await counterpickMap(page, mode, stageId as StageId).click();
picked.push({ mode, stageId: stageId as StageId });
stageId++;
}
}
return picked;
}

View File

@@ -0,0 +1,135 @@
import type { Page } from "@playwright/test";
import { NZAP_TEST_DISCORD_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
export type Factories = Awaited<ReturnType<typeof loadFactories>>;
let boundDbPath: string | null = null;
/**
* Binds this worker's database file and hands back the factories. The only module
* allowed to import seed code into the e2e process: the app db connection opens at
* import time from `DB_PATH`, so everything reaching `~/db/sql` has to be imported
* dynamically, after the env is set.
*/
export async function loadFactories(parallelIndex: number) {
if (process.env.NODE_ENV === "test") {
throw new Error(
"NODE_ENV=test makes the app db an in-memory copy — factory writes would be discarded",
);
}
// a `.env` value is fine to override, an earlier bind to another worker's file is not
const dbPath = `db-test-e2e-${parallelIndex}.sqlite3`;
if (boundDbPath && boundDbPath !== dbPath) {
throw new Error(
`The db connection is already bound to ${boundDbPath}, refusing to rebind to ${dbPath}`,
);
}
process.env.DB_PATH = dbPath;
boundDbPath = dbPath;
return {
backdate: (await import("~/db/seed/core/backdate")).backdate,
reseason: (await import("~/db/seed/core/reseason")).reseason,
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),
BadgeFactory: await import("~/db/seed/factories/BadgeFactory"),
BuildFactory: await import("~/db/seed/factories/BuildFactory"),
CalendarEventFactory: await import(
"~/db/seed/factories/CalendarEventFactory"
),
FriendRequestFactory: await import(
"~/db/seed/factories/FriendRequestFactory"
),
FriendshipFactory: await import("~/db/seed/factories/FriendshipFactory"),
LFGPostFactory: await import("~/db/seed/factories/LFGPostFactory"),
LiveStreamFactory: await import("~/db/seed/factories/LiveStreamFactory"),
NotificationFactory: await import(
"~/db/seed/factories/NotificationFactory"
),
SavedCalendarEventFactory: await import(
"~/db/seed/factories/SavedCalendarEventFactory"
),
ScrimPostFactory: await import("~/db/seed/factories/ScrimPostFactory"),
SkillFactory: await import("~/db/seed/factories/SkillFactory"),
SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"),
SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"),
SQReadyCheckFactory: await import(
"~/db/seed/factories/SQReadyCheckFactory"
),
TeamFactory: await import("~/db/seed/factories/TeamFactory"),
TournamentFactory: await import("~/db/seed/factories/TournamentFactory"),
TournamentOrganizationFactory: await import(
"~/db/seed/factories/TournamentOrganizationFactory"
),
TournamentTeamFactory: await import(
"~/db/seed/factories/TournamentTeamFactory"
),
TrophyFactory: await import("~/db/seed/factories/TrophyFactory"),
UserFactory: await import("~/db/seed/factories/UserFactory"),
UserReportFactory: await import("~/db/seed/factories/UserReportFactory"),
VodFactory: await import("~/db/seed/factories/VodFactory"),
XRankPlacementFactory: await import(
"~/db/seed/factories/XRankPlacementFactory"
),
};
}
/**
* Brings the worker's database to the state every test starts from: empty except
* for the anchor users whose ids production permission logic keys off, with the
* server's caches flushed. Runs as an auto-use fixture, tests do not call it.
*/
export async function resetForTest(page: Page, factories: Factories) {
const { dbReset } = await import("~/db/reset");
await dbReset();
await factories.UserFactory.createAdmin({
discordId: ADMIN_DISCORD_ID,
discordName: "Sendou",
});
await factories.UserFactory.createRegular({
discordId: NZAP_TEST_DISCORD_ID,
discordName: "N-ZAP",
});
await flushIfDirty(page, { resetDevOverrides: true });
}
/**
* Refreshes the server's in-process caches if anything was written since the last
* flush. Called by the helpers that make the browser talk to the server — tests do
* not call it themselves.
*/
export async function flushIfDirty(
page: Page,
{ resetDevOverrides = false } = {},
) {
const { isDatabaseDirty, markDatabaseClean } = await import(
"~/db/write-tracker"
);
if (!isDatabaseDirty() && !resetDevOverrides) return;
// deliberately not retryPost: that helper calls this one, and would recurse
const response = await page.request.post("/refresh-caches", {
timeout: 7_500,
form: { resetDevOverrides: String(resetDevOverrides) },
});
if (!response.ok()) {
throw new Error(`Cache refresh failed with status ${response.status()}`);
}
markDatabaseClean();
}
/** Fails the test if it ended with factory writes the server was never told about. */
export async function assertFlushed() {
const { isDatabaseDirty } = await import("~/db/write-tracker");
if (!isDatabaseDirty()) return;
throw new Error(
"Test ended with database writes the server never saw — follow factory calls with a helper that talks to the server (navigate, submit, impersonate)",
);
}

View File

@@ -0,0 +1,273 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { z } from "zod";
import { getFormFieldMetadata } from "~/form/fields";
import type { FormField } from "~/form/types";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function loadTranslations(): Record<string, Record<string, string>> {
const localesPath = path.resolve(__dirname, "../../locales/en");
return {
forms: JSON.parse(
fs.readFileSync(path.join(localesPath, "forms.json"), "utf-8"),
),
common: JSON.parse(
fs.readFileSync(path.join(localesPath, "common.json"), "utf-8"),
),
};
}
const translations = loadTranslations();
function resolveTranslation(key: string): string {
// Handle keys like "common:forms.name" or "team:newTeam.header"
const [namespace, translationPath] = key.includes(":")
? key.split(":")
: ["common", key];
const nsTranslations = translations[namespace];
if (!nsTranslations) {
return key;
}
const value = nsTranslations[translationPath as keyof typeof nsTranslations];
return typeof value === "string" ? value : key;
}
type Inferred<T extends z.ZodRawShape> = z.infer<z.ZodObject<T>>;
type FillableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: string extends Inferred<T>[K] ? K : never;
}[keyof Inferred<T>];
type CheckableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends boolean ? K : never;
}[keyof Inferred<T>];
type SelectableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends string | null | undefined
? K
: never;
}[keyof Inferred<T>];
type FormFieldHelpers<T extends z.ZodRawShape> = {
fill: (name: FillableKeys<T>, value: string) => Promise<void>;
check: (name: CheckableKeys<T>) => Promise<void>;
uncheck: (name: CheckableKeys<T>) => Promise<void>;
checkItems: (name: keyof Inferred<T>, itemValues: string[]) => Promise<void>;
select: (name: SelectableKeys<T>, optionText: string) => Promise<void>;
selectUser: (name: keyof Inferred<T>, userName: string) => Promise<void>;
selectWeapons: (
name: keyof Inferred<T>,
weaponNames: string[],
) => Promise<void>;
setDateTime: (name: keyof Inferred<T>, date: Date) => Promise<void>;
setDate: (name: keyof Inferred<T>, date: Date) => Promise<void>;
setImage: (name: keyof Inferred<T>, filePath: string) => Promise<void>;
submit: () => Promise<void>;
getLabel: <K extends keyof Inferred<T>>(name: K) => string;
getItemLabel: (name: keyof Inferred<T>, itemValue: string) => string;
};
export function createFormHelpers<T extends z.ZodRawShape>(
page: Page,
schema: z.ZodObject<T>,
options?: { submitTestId?: string },
): FormFieldHelpers<T> {
const submitTestId = options?.submitTestId ?? "submit-button";
const getFieldMetadata = (name: string): FormField | undefined => {
const fieldSchema = schema.shape[name];
if (!fieldSchema) return undefined;
return getFormFieldMetadata(fieldSchema as z.ZodType);
};
const getLabel = (name: string): string => {
const metadata = getFieldMetadata(name);
if (!metadata || !("label" in metadata) || !metadata.label) {
throw new Error(`No label found for field: ${name}`);
}
return resolveTranslation(metadata.label);
};
// match the whole label (allowing the trailing space and optional required " *" the
// Label component always renders) so a short label like "Name" doesn't also pick up
// "Bracket name" or "Require in-game names"
const byLabel = (label: string) => {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return page.getByLabel(new RegExp(`^${escaped} *\\*?$`, "i"));
};
const getItemLabel = (name: string, itemValue: string): string => {
const metadata = getFieldMetadata(name);
if (!metadata || !("items" in metadata) || !Array.isArray(metadata.items)) {
throw new Error(`No items found for field: ${name}`);
}
const item = metadata.items.find(
(i: { value: string }) => i.value === itemValue,
);
if (!item || typeof item.label !== "string") {
throw new Error(`No item found with value: ${itemValue}`);
}
return resolveTranslation(item.label);
};
return {
getLabel(name) {
return getLabel(String(name));
},
getItemLabel(name, itemValue) {
return getItemLabel(String(name), itemValue);
},
async fill(name, value) {
const label = getLabel(String(name));
await byLabel(label).fill(value);
},
async check(name) {
const label = getLabel(String(name));
const locator = byLabel(label);
const isChecked = await locator.isChecked();
if (!isChecked) {
await locator.click({ force: true });
}
},
async uncheck(name) {
const label = getLabel(String(name));
const locator = byLabel(label);
const isChecked = await locator.isChecked();
if (isChecked) {
await locator.click({ force: true });
}
},
async checkItems(name, itemValues) {
const metadata = getFieldMetadata(String(name));
if (
!metadata ||
!("items" in metadata) ||
!Array.isArray(metadata.items)
) {
throw new Error(`No items found for field: ${String(name)}`);
}
for (const item of metadata.items as Array<{
value: string;
label: string;
}>) {
const itemLabelText = resolveTranslation(item.label);
const locator = page.getByLabel(itemLabelText);
const isChecked = await locator.isChecked();
const shouldBeChecked = itemValues.includes(item.value);
if (shouldBeChecked && !isChecked) {
await locator.click();
} else if (!shouldBeChecked && isChecked) {
await locator.click();
}
}
},
async select(name, optionValue) {
const label = getLabel(String(name));
const locator = byLabel(label);
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
const metadata = getFieldMetadata(String(name));
let resolvedOptionText = optionValue;
if (metadata && "items" in metadata && Array.isArray(metadata.items)) {
const item = metadata.items.find(
(i: { value: string }) => i.value === optionValue,
);
if (item && typeof item.label === "string") {
resolvedOptionText = resolveTranslation(item.label);
}
}
if (tagName === "select") {
await locator.selectOption(resolvedOptionText);
} else {
await locator.click();
await page.getByRole("option", { name: resolvedOptionText }).click();
}
},
async selectUser(name, userName) {
const label = getLabel(String(name));
// role + non-exact name: the trigger's accessible name is e.g. "User search User *"
const comboboxButton = page.getByRole("button", { name: label });
const searchInput = page.getByTestId("user-search-input");
const option = page.getByTestId("user-search-item").first();
await expect(comboboxButton).not.toBeDisabled();
await comboboxButton.click();
await searchInput.fill(userName);
await expect(option).toBeVisible();
await page.keyboard.press("Enter");
},
async selectWeapons(_name, weaponNames) {
for (const weaponName of weaponNames) {
await page.getByTestId("weapon-select").click();
await page.getByPlaceholder("Search weapons...").fill(weaponName);
await page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`weapon-select-option-${weaponName}`)
.click();
}
},
async setDateTime(name, date) {
const label = getLabel(String(name));
const hours = date.getHours();
const fillSpinbutton = async (spinName: string, value: string) => {
await page
.getByRole("spinbutton", {
name: new RegExp(`^${spinName}, ${label}`),
})
.fill(value);
};
await fillSpinbutton("year", date.getFullYear().toString());
await fillSpinbutton("month", (date.getMonth() + 1).toString());
await fillSpinbutton("day", date.getDate().toString());
await fillSpinbutton("hour", String(hours % 12 || 12));
await fillSpinbutton(
"minute",
date.getMinutes().toString().padStart(2, "0"),
);
await fillSpinbutton("AM/PM", hours >= 12 ? "PM" : "AM");
},
async setDate(name, date) {
const label = getLabel(String(name));
const fillSpinbutton = async (spinName: string, value: string) => {
await page
.getByRole("spinbutton", {
name: new RegExp(`^${spinName}, ${label}`),
})
.fill(value);
};
await fillSpinbutton("year", date.getFullYear().toString());
await fillSpinbutton("month", (date.getMonth() + 1).toString());
await fillSpinbutton("day", date.getDate().toString());
},
async setImage(name, filePath) {
const label = getLabel(String(name));
await page.getByLabel(label).setInputFiles(filePath);
},
async submit() {
await page.getByTestId(submitTestId).click();
},
};
}

View File

@@ -0,0 +1,365 @@
import {
test as base,
expect,
type Locator,
type Page,
type Response,
} from "@playwright/test";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import {
assertFlushed,
type Factories,
flushIfDirty,
loadFactories,
resetForTest,
} from "./factories";
try {
process.loadEnvFile();
} catch {
// .env is optional; in CI env vars come from the host (e2e-tests.yml creates none)
}
export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500;
export const MOBILE_VIEWPORT = { width: 375, height: 667 };
export const TABLET_VIEWPORT = { width: 768, height: 1024 };
/** Registered (>=1024) ports on the WHATWG fetch bad port list: Node's fetch
* fails on them with "bad port" and Chromium with ERR_UNSAFE_PORT, so no worker
* server may listen on one (e.g. base port 6673 would put worker 6 on 6679). */
const UNSAFE_PORTS = new Set([
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 5432, 5500, 5938, 6000,
6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
]);
/** The port of the given worker's server: base port + index, skipping unsafe ports. */
export function e2eWorkerPort(workerIndex: number) {
let port = E2E_BASE_PORT - 1;
for (let i = 0; i <= workerIndex; i++) {
do {
port++;
} while (UNSAFE_PORTS.has(port));
}
return port;
}
/** The port a test can listen on to receive the given worker's Discord webhook calls. */
export function e2eWebhookPort(workerIndex: number) {
return e2eWorkerPort(workerIndex) + 2000;
}
type WorkerFixtures = {
workerPort: number;
workerBaseURL: string;
factories: Factories;
};
type TestFixtures = {
resetDatabase: undefined;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
context: async ({ context }, use) => {
// Google Fonts load with display=swap and every test context re-fetches
// them, so the swap reflows the page mid-test (e.g. re-collapsing the
// tournament nav between a visibility check and a click). Block them so
// layout settles at first paint and stays put.
await context.route(
/^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
(route) => route.abort(),
);
await use(context);
},
workerPort: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring
async ({}, use, workerInfo) => {
const port = e2eWorkerPort(workerInfo.parallelIndex);
await use(port);
},
{ scope: "worker" },
],
workerBaseURL: [
async ({ workerPort }, use) => {
await use(`http://localhost:${workerPort}`);
},
{ scope: "worker" },
],
baseURL: async ({ workerBaseURL }, use) => {
await use(workerBaseURL);
},
factories: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring
async ({}, use, workerInfo) => {
await use(await loadFactories(workerInfo.parallelIndex));
},
{ scope: "worker" },
],
resetDatabase: [
async ({ page, factories }, use) => {
await resetForTest(page, factories);
await use(undefined);
// fails loudly instead of leaving the next test to guess
await assertFlushed();
},
{ auto: true },
],
});
export { expect };
export async function selectWeapon({
page,
name,
testId = "weapon-select",
}: {
page: Page;
name: string;
testId?: string;
}) {
await page.getByTestId(testId).click();
await page.getByPlaceholder("Search weapons...").fill(name);
await page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`weapon-select-option-${name}`)
.click();
}
export async function selectStage({
page,
name,
testId = "stage-select",
nth,
}: {
page: Page;
name: string;
testId?: string;
nth?: number;
}) {
const select =
nth !== undefined
? page.getByTestId(testId).nth(nth)
: page.getByTestId(testId);
await select.click();
await page.getByPlaceholder("Search stages...").fill(name);
await page.getByTestId(`stage-select-option-${name}`).click();
}
export async function selectUser({
page,
userName,
labelName,
exact = false,
within,
}: {
page: Page;
userName: string;
labelName: string;
exact?: boolean;
/** Scopes the combobox lookup, for pages carrying the label on more than one element. */
within?: Locator;
}) {
const comboboxButton = (within ?? page).getByLabel(labelName, { exact });
const searchInput = page.getByTestId("user-search-input");
const option = page.getByTestId("user-search-item").first();
await expect(comboboxButton).not.toBeDisabled();
await comboboxButton.click();
await searchInput.fill(userName);
await expect(option).toBeVisible();
await page.keyboard.press("Enter");
}
export async function selectTournament({
page,
query,
}: {
page: Page;
query: string;
}) {
const item = page.getByTestId("tournament-search-item");
await page.getByRole("button", { name: /Tournament search/i }).click();
await page.getByTestId("tournament-search-input").fill(query);
await expect(item.first()).toBeVisible();
await item.first().click();
}
/** 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);
// Rewrite absolute URLs with localhost to use the worker's baseURL
// This handles invite links and other URLs embedded with VITE_SITE_DOMAIN
let targetUrl = url;
if (url.startsWith("http://localhost:")) {
const urlObj = new URL(url);
// Extract just the path and search params, let Playwright use the correct baseURL
targetUrl = urlObj.pathname + urlObj.search;
}
await page.goto(targetUrl);
await expectIsHydrated(page);
}
/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */
export async function expectIsHydrated(page: Page) {
await expect(page.getByTestId("hydrated")).toHaveCount(1);
}
export function impersonate(page: Page, userId = ADMIN_ID) {
return retryPost(page, "impersonate", `/auth/impersonate?id=${userId}`);
}
/**
* Makes the worker's server resolve every season as over, so tests can cover the
* season boundary. Undone before the next test starts.
*/
export async function endSeason(page: Page) {
const response = await retryPost(page, "endSeason", "/end-season");
if (!response?.ok()) {
throw new Error(
`Ending the season 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", {
form: { name },
});
if (!response?.ok()) {
throw new Error(
`Running routine ${name} failed with status ${response?.status()}`,
);
}
}
/**
* Direct (non-browser) POST that retries on transient network failures such as
* "socket hang up", which the dev server can produce intermittently under load.
* Only safe for idempotent endpoints.
*/
async function retryPost(
page: Page,
name: string,
url: string,
options?: Parameters<Page["request"]["post"]>[1],
) {
await flushIfDirty(page);
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
return await page.request.post(url, { timeout: 7_500, ...options });
} catch (error) {
if (attempt === MAX_ATTEMPTS) throw error;
}
}
throw new Error(`${name}: unreachable`);
}
/** Clicks a submit button and waits for the POST it fires. Takes a locator when
* the test id alone is ambiguous, e.g. one button per card on a list page. */
export async function submit(page: Page, target?: string | Locator) {
const button =
typeof target === "object"
? target
: page.getByTestId(target ?? "submit-button");
await waitForPOSTResponse(page, async () => {
await button.click();
});
// Toast flash params are stripped right after via a replace navigation
// (without revalidation); wait for it so it can't abort a later click.
await expect(page).not.toHaveURL(/__(?:success|error)=/);
}
export async function waitForPOSTResponse(page: Page, cb: () => Promise<void>) {
await flushIfDirty(page);
const MAX_ATTEMPTS = 3;
const PER_ATTEMPT_TIMEOUT = 10_000;
// React Aria buttons fire their handler on press end. Occasionally a click
// registers the press start (the button goes `:active`) but the press never
// completes into a submit, so no POST fires — e.g. when a re-render lands
// mid-press. Re-issue the action when the expected POST doesn't arrive
// within the per-attempt window.
let response: Response | undefined;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const responsePromise = page.waitForResponse(
(res) => res.request().method() === "POST",
{ timeout: PER_ATTEMPT_TIMEOUT },
);
await cb();
try {
response = await responsePromise;
break;
} catch (error) {
if (attempt === MAX_ATTEMPTS) throw error;
}
}
// The POST's revalidation (and any redirect it drives) is still in flight;
// an interaction landing mid-flight aborts it, and routes that opt out of
// revalidation on navigation (e.g. to.$id) then keep the stale data.
await expectRouterIdle(page);
return response!;
}
/** Waits until no navigation, revalidation or fetcher is in flight. */
async function expectRouterIdle(page: Page) {
// A submit's redirect plus the target page's loaders can exceed the default
// expect timeout when the full suite is loading all workers.
try {
await expect(page.getByTestId("hydrated")).toHaveAttribute(
"data-router-idle",
"true",
{ timeout: 15_000 },
);
} catch (error) {
// data-router-busy names what is still in flight, which the attribute
// assertion's own message does not
const busy = await page
.getByTestId("hydrated")
.getAttribute("data-router-busy")
.catch(() => null);
throw new Error(
`Router never went idle at ${page.url()} (in flight: ${busy ?? "unknown"})`,
{ cause: error },
);
}
}
/** Asserts the page rendered rather than the error boundary catching something. */
export async function expectNoErrorPage(page: Page) {
await expect(page.getByTestId("error-page")).toHaveCount(0);
}
export function isNotVisible(locator: Locator) {
return expect(locator).toHaveCount(0);
}
export function modalClickConfirmButton(page: Page) {
return submit(page, "confirm-button");
}
/**
* Clicks a tournament nav tab by its testId, opening the overflow ("More") menu
* first when the tab has collapsed into it on the current viewport.
*/
export async function clickNavTab(page: Page, testId: string) {
const visibleTab = page.locator(`[data-testid="${testId}"]:visible`);
if ((await visibleTab.count()) === 0) {
await page.getByRole("button", { name: "More" }).click();
}
await visibleTab.click();
}

View File

@@ -0,0 +1,73 @@
import type { Locator } from "@playwright/test";
import { addDays, addHours } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import type { Factories } from "./factories";
import { expect } from "./playwright";
export const NO_EVENTS_TEXT = "No upcoming events";
export const NO_STREAMS_TEXT = "No streams currently";
export const NO_FRIENDS_TEXT = "Add friends to see their activity here";
export const LOGGED_OUT_FRIENDS_TEXT =
"Log in to follow your friends' activity";
export function inHours(hours: number) {
return dateToDatabaseTimestamp(addHours(new Date(), hours));
}
export function inDays(days: number) {
return dateToDatabaseTimestamp(addDays(new Date(), days));
}
/** The Twitch account `LiveStreamFactory` gives a user's stream. */
export function streamAccountOf(userId: number) {
return `stream_${userId}`;
}
/** Users the sidebar rows can be found by, one per name. */
export function createNamedUsers(
factories: Factories,
names: string[],
overrides?: { twitch?: string },
) {
return factories.UserFactory.createMany(names.length, (index) => ({
...overrides,
discordName: names[index],
}));
}
export async function createUserIds(factories: Factories, count: number) {
const users = await factories.UserFactory.createMany(count);
return users.map((user) => user.id);
}
export function befriend(
factories: Factories,
friendIds: number[],
userId = NZAP_TEST_ID,
) {
return Promise.all(
friendIds.map((friendId) =>
factories.FriendshipFactory.create({
userOneId: userId,
userTwoId: friendId,
}),
),
);
}
/** Asserts the rows are rendered in the order given, top to bottom. */
export async function expectTopToBottom(rows: Locator[]) {
const tops: number[] = [];
for (const row of rows) {
await expect(row).toBeVisible();
const box = await row.boundingBox();
invariant(box, "Visible row has no bounding box");
tops.push(box.y);
}
expect(tops).toEqual([...tops].sort((a, b) => a - b));
}

View File

@@ -0,0 +1,293 @@
import { addHours, subMinutes } from "date-fns";
import type { TournamentSettings } from "~/db/tables-json";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import type { Factories } from "./factories";
export const ROSTER_SIZE = 4;
type BracketProgression = TournamentSettings["bracketProgression"];
export const DOUBLE_ELIMINATION: BracketProgression = [
{
type: "double_elimination",
name: "Main bracket",
requiresCheckIn: false,
settings: {},
},
];
/* Single group of 4 teams plays: R1 = matches 1 (team 1 vs. 4) & 2 (team 3 vs. 2),
* R2 = matches 3 (team 2 vs. 4) & 4 (team 1 vs. 3), R3 = matches 5 & 6 (team 2 vs. 1). */
export const ROUND_ROBIN: BracketProgression = [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
];
export const RR_TO_SE: BracketProgression = [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Final stage",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
];
/** With 16 teams: 4 round robin groups of 4 where every team advances to a 16 team
* single elimination — the format shape of GitHub issue #2607. */
export const RR_TOP_4_TO_SE: BracketProgression = [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Final stage",
requiresCheckIn: false,
settings: { thirdPlaceMatch: false },
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4] }],
},
];
export const RR_TO_SE_WITH_UNDERGROUND: BracketProgression = [
...RR_TO_SE,
{
type: "single_elimination",
name: "Underground bracket",
requiresCheckIn: true,
settings: {},
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
];
export const SOS_BRACKETS: BracketProgression = [
{
type: "round_robin",
name: "Groups stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Great White",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1] }],
},
{
type: "single_elimination",
name: "Hammerhead",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [2] }],
},
{
type: "single_elimination",
name: "Mako",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [3] }],
},
{
type: "single_elimination",
name: "Lantern",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [4] }],
},
];
export const SWISS_TO_TOP_CUT: BracketProgression = [
{
type: "swiss",
name: "Swiss",
requiresCheckIn: false,
settings: { groupCount: 2, roundCount: 4 },
},
{
type: "single_elimination",
name: "Top Cut",
requiresCheckIn: false,
settings: { thirdPlaceMatch: false },
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4] }],
},
];
/** Teams advance at 2 wins and are eliminated at 4 losses (roundCount - advanceThreshold + 1). */
export const SWISS_EARLY_ADVANCE_TO_TOP_CUT: BracketProgression = [
{
type: "swiss",
name: "Swiss",
requiresCheckIn: false,
settings: { groupCount: 1, roundCount: 5, advanceThreshold: 2 },
},
{
type: "single_elimination",
name: "Top Cut",
requiresCheckIn: false,
settings: { thirdPlaceMatch: false },
sources: [{ bracketIdx: 0, placements: [] }],
},
];
export const TO_MAP_POOL = ([1, 2, 3, 4, 6, 7, 8, 10] as StageId[]).flatMap(
(stageId) =>
(["SZ", "TC", "RM", "CB"] as ModeShort[]).map((mode) => ({
mode,
stageId,
})),
);
export type TeamSeed = {
/** Users put on the roster ahead of freshly created filler users. */
members?: number[];
rosterSize?: number;
isCheckedIn?: boolean;
};
/** `count` checked in teams with full rosters of fresh users. */
export function teamSeeds(count: number): TeamSeed[] {
return Array.from({ length: count }, () => ({}));
}
/** Registers a team per seed, named by seeding order ("Team 1", "Team 2", ...). */
export async function createTeams(
factories: Factories,
tournamentId: number,
seeds: TeamSeed[],
) {
const teams = [];
for (const [i, seed] of seeds.entries()) {
const presetMembers = seed.members ?? [];
const rosterSize = seed.rosterSize ?? ROSTER_SIZE;
const fillerUsers = await factories.UserFactory.createMany(
rosterSize - presetMembers.length,
);
teams.push(
await factories.TournamentTeamFactory.create(
{
tournamentId,
team: {
name: `Team ${i + 1}`,
prefersNotToHost: 0 as const,
teamId: null,
},
memberUserIds: [
...presetMembers,
...fillerUsers.map((user) => user.id),
],
},
{ isCheckedIn: seed.isCheckedIn ?? true },
),
);
}
return teams;
}
/** A start time in the past: check-in is over and brackets can be started from the UI. */
export function startedTournamentTimes() {
return [dateToDatabaseTimestamp(subMinutes(new Date(), 30))];
}
/**
* A tournament running an unfinished match: two checked-in teams and the bracket
* started, which is all it takes for both teams to be "in a match". The friend and
* their teammate play the opponent, and every user given is on the roster it is
* named for.
*/
export async function createInProgressMatch(
factories: Factories,
{
name,
friendId,
teammateId,
opponentId,
tier,
}: {
name: string;
friendId: number;
teammateId?: number;
opponentId?: number;
tier?: TournamentTierNumber;
},
) {
const tournament = await factories.TournamentFactory.create(
{
authorId: ADMIN_ID,
name,
startTimes: startedTournamentTimes(),
},
{ tier },
);
const teams = await createTeams(factories, tournament.id, [
{ members: [friendId, ...(teammateId ? [teammateId] : [])] },
{ members: opponentId ? [opponentId] : [] },
]);
const [match] = await factories.TournamentFactory.startBracket(tournament.id);
invariant(match, "Starting the bracket created no match");
return { tournament, teams, matchId: match.id };
}
/** An upcoming tournament where the given user's team is looking for subs. */
export async function createSubSeekingTournament(
factories: Factories,
{ name, subId }: { name: string; subId: number },
) {
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name,
startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
});
await factories.TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds: [subId] },
{ isLooking: true },
);
return { tournament };
}
/**
* A tournament where the given user is waiting for their next match: three teams
* in a single elimination bracket leave the top seed with a bye into the final,
* whose other side is still being played for.
*/
export async function createTournamentWithByeTeam(
factories: Factories,
{ name, waitingUserId }: { name: string; waitingUserId: number },
) {
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name,
startTimes: startedTournamentTimes(),
});
await createTeams(factories, tournament.id, [
{ members: [waitingUserId] },
{},
{},
]);
await factories.TournamentFactory.startBracket(tournament.id);
return { tournament };
}

View File

@@ -0,0 +1,83 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { LFGPage } from "./pages/lfg/lfg-page";
import { NewLFGPostPage } from "./pages/lfg/new-lfg-post-page";
test.describe("LFG", () => {
test("adds a new lfg post", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await new AnythingAdder(page).add("lfgPost");
const newPost = new NewLFGPostPage(page);
await newPost.form.fill("postText", "looking for a cool team");
await newPost.save();
const lfg = new LFGPage(page);
await expect(lfg.locators.addFilterButton).toBeVisible();
await expect(lfg.post("looking for a cool team")).toBeVisible();
});
test("creates post with custom languages", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await newPost.form.fill("postText", "looking for Japanese/Korean team");
await newPost.checkLanguage("日本語");
await newPost.checkLanguage("한국어");
await newPost.save();
await expect(new LFGPage(page).languagePill("JA / KO")).toBeVisible();
});
test("edits post languages", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await newPost.form.fill("postText", "test post for language editing");
await newPost.checkLanguage("Dansk");
await newPost.save();
const lfg = new LFGPage(page);
await expect(lfg.languagePill("DA")).toBeVisible();
await expect(lfg.post("test post for language editing")).toBeVisible();
const editPost = await lfg.editFirstPost();
await editPost.uncheckLanguage("Dansk");
await editPost.checkLanguage("Español");
await editPost.save();
await expect(lfg.locators.addFilterButton).toBeVisible();
await expect(lfg.post("test post for language editing")).toBeVisible();
await expect(lfg.languagePill("ES")).toBeVisible();
});
test("filters posts by language", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await newPost.form.fill("postText", "Japanese speaking team");
await newPost.checkLanguage("日本語");
await newPost.save();
const lfg = new LFGPage(page);
await expect(lfg.post("Japanese speaking team")).toBeVisible();
await lfg.addLanguageFilter();
await lfg.selectFilterLanguage("ja");
await expect(lfg.post("Japanese speaking team")).toBeVisible();
await lfg.selectFilterLanguage("es");
await expect(lfg.post("Japanese speaking team")).not.toBeVisible();
});
});

View File

@@ -0,0 +1,77 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { SENDOUQ_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
MOBILE_VIEWPORT,
navigate,
TABLET_VIEWPORT,
test,
} from "./helpers/playwright";
import { MobileNav } from "./pages/layout/mobile-nav";
import { TopNavMenus } from "./pages/layout/top-nav-menus";
test.describe("Navigation", () => {
test("desktop navigation", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await expect(topNav.link("Scrims")).toBeVisible();
await topNav.link("SendouQ").click();
await expect(page).toHaveURL(SENDOUQ_PAGE);
await expect(topNav.link("SendouQ")).not.toBeVisible();
await topNav.open("Tools");
await topNav.link("Analyzer").click();
await expect(page).toHaveURL(/\/analyzer/);
await topNav.open("Community");
await topNav.link("Builds").click();
await expect(page).toHaveURL(/\/builds/);
});
test("mobile navigation", async ({ page }) => {
await page.setViewportSize(MOBILE_VIEWPORT);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const mobileNav = new MobileNav(page);
await expect(mobileNav.tab("menu")).toBeVisible();
await expect(mobileNav.tab("friends")).toBeVisible();
await expect(mobileNav.tab("tourneys")).toBeVisible();
await expect(mobileNav.tab("chat")).toBeVisible();
await expect(mobileNav.tab("you")).toBeVisible();
await mobileNav.openPanel("menu");
await expect(mobileNav.menuLink("Analyzer")).toBeVisible();
await expect(mobileNav.menuLink("Builds")).toBeVisible();
await mobileNav.switchPanel("you");
await expect(mobileNav.locators.youPanelUsername).toBeVisible();
await mobileNav.closePanel();
await expect(mobileNav.locators.youPanelUsername).toHaveCount(0);
await mobileNav.openPanel("menu");
await mobileNav.menuLink("SendouQ").click();
await expect(page).toHaveURL(SENDOUQ_PAGE);
await expect(mobileNav.menuLink("SendouQ")).not.toBeVisible();
});
test("tablet navigation", async ({ page }) => {
await page.setViewportSize(TABLET_VIEWPORT);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await topNav.close();
await expect(new MobileNav(page).tab("menu")).not.toBeVisible();
});
});

View File

@@ -0,0 +1,38 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { NotificationPopover } from "./pages/layout/notification-popover";
const UNSEEN_COUNT = 2;
test.describe("Notifications", () => {
test("opening the popover clears the bell dot but keeps the unseen dots listed", async ({
page,
factories,
}) => {
for (let seasonNth = 1; seasonNth <= UNSEEN_COUNT; seasonNth++) {
await factories.NotificationFactory.create({
notification: { type: "SEASON_STARTED", meta: { seasonNth } },
users: [{ userId: ADMIN_ID, seen: 0 }],
});
}
await impersonate(page);
await navigate({ page, url: "/" });
const notifications = new NotificationPopover(page);
await expect(notifications.locators.bellDot).toBeVisible();
await notifications.open();
await expect(notifications.locators.bellDot).toBeHidden();
await expect(notifications.locators.unseenDots).toHaveCount(UNSEEN_COUNT);
await notifications.close();
await expect(notifications.locators.items).toHaveCount(0);
await notifications.open();
await expect(notifications.locators.items).toHaveCount(UNSEEN_COUNT);
await expect(notifications.locators.unseenDots).toHaveCount(0);
});
});

View File

@@ -0,0 +1,77 @@
import { expect, test } from "./helpers/playwright";
import { ObjectDamageCalculatorPage } from "./pages/object-damage-calculator/object-damage-calculator-page";
test.describe("Object Damage Calculator", () => {
test("operates damage type select, max damage > min damage", async ({
page,
}) => {
const calculator = new ObjectDamageCalculatorPage(page);
await calculator.goto();
const hp = calculator.hitPoints();
const dmg = calculator.damage();
const htd = calculator.hitsToDestroy();
const hpBefore = (await hp.textContent())!;
const dmgBefore = (await dmg.textContent())!;
const htdBefore = (await htd.textContent())!;
// test hits to destroy calculation
expect(Number(htdBefore)).toBe(
Math.ceil(Number(hpBefore) / Number(dmgBefore)),
);
await calculator.selectDamageType("NORMAL_MIN");
// select did what we expect it to do
await expect(hp).toHaveText(hpBefore);
await expect(dmg).not.toHaveText(dmgBefore);
await expect(htd).not.toHaveText(htdBefore);
});
test("changes weapon and saves it to url", async ({ page }) => {
const calculator = new ObjectDamageCalculatorPage(page);
await calculator.goto();
const dmg = calculator.damage();
const dmgBefore = (await dmg.textContent())!;
await calculator.selectWeapon("Luna Blaster");
await expect(dmg).not.toHaveText(dmgBefore);
await page.reload();
await expect(dmg).not.toHaveText(dmgBefore);
});
test("multiplier switch increases damage", async ({ page }) => {
const calculator = new ObjectDamageCalculatorPage(page);
await calculator.goto();
await calculator.selectWeapon("Tri-Stringer");
const dmg = calculator.damage();
const dmgBefore = (await dmg.textContent())!;
await calculator.toggleMultiplier();
// Multiplier is on by default
await expect(dmg).not.toHaveText(dmgBefore);
});
test("object hp increases when ability points added", async ({ page }) => {
const calculator = new ObjectDamageCalculatorPage(page);
await calculator.goto();
const crabTankHp = calculator.hitPoints();
const crabTankHpBefore = (await crabTankHp.textContent())!;
const splashWallHp = calculator.hitPoints("Wsb_Shield");
const splashWallHpBefore = (await splashWallHp.textContent())!;
await calculator.selectAbilityPoints(10);
// Crab Tank doesn't gain HP from ability points
await expect(crabTankHp).toHaveText(crabTankHpBefore);
// ... but Splash Wall does
await expect(splashWallHp).not.toHaveText(splashWallHpBefore);
});
});

View File

@@ -0,0 +1,208 @@
import { addHours } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
expect,
impersonate,
isNotVisible,
navigate,
test,
waitForPOSTResponse,
} from "./helpers/playwright";
import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { NewOrganizationPage } from "./pages/org/new-organization-page";
import { OrganizationPage } from "./pages/org/organization-page";
import { TournamentPage } from "./pages/tournament/tournament-page";
const ORGANIZATION_NAME = "Sendou.ink";
const TOURNAMENT_NAME = "PICNIC #2";
const PLAYER_NAME = "Banned Bob";
test.describe("Tournament Organization", () => {
test("can create a new organization", async ({ page, factories }) => {
const organizer = await factories.UserFactory.create(null, {
roles: ["TOURNAMENT_ORGANIZER"],
});
await impersonate(page, organizer.id);
await navigate({ page, url: "/" });
await new AnythingAdder(page).add("organization");
const newOrganization = new NewOrganizationPage(page);
await newOrganization.form.fill("name", "Test Organization");
await newOrganization.save();
const organization = new OrganizationPage(page);
await expect(organization.locators.editButton).toBeVisible();
});
test("user can be promoted to admin gaining org controls and can edit tournaments", async ({
page,
factories,
}) => {
const org = await factories.TournamentOrganizationFactory.create(
{ ownerId: ADMIN_ID, name: ORGANIZATION_NAME },
{ members: [{ userId: NZAP_TEST_ID, role: "MEMBER" }] },
);
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
organizationId: org.id,
startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
});
const organization = new OrganizationPage(page);
// 1. As a regular user, verify edit controls are not visible
await impersonate(page, NZAP_TEST_ID);
await organization.goto(org.slug);
await isNotVisible(organization.locators.editButton);
// 2. As admin, promote user to admin
await impersonate(page, ADMIN_ID);
await organization.goto(org.slug);
const organizationEdit = await organization.openEdit();
await organizationEdit.setMemberRole("N-ZAP", "ADMIN");
await organizationEdit.save();
// Establish the organization so its admins can edit tournament event info
await organization.goto(org.slug);
await organization.establish();
// 3. As the promoted user, verify edit controls are visible and page can be accessed
await impersonate(page, NZAP_TEST_ID);
await organization.goto(org.slug);
await organization.openEdit();
await expect(organizationEdit.locators.title).toBeVisible();
// 4. As the promoted user, verify they can edit tournaments
const tournamentPage = new TournamentPage(page);
await tournamentPage.goto(tournament.id);
const tournamentAdmin = await tournamentPage.nav.openAdmin();
const calendarEvent = await tournamentAdmin.editEventInfo();
await expect(calendarEvent.locators.nameInput).toHaveValue(TOURNAMENT_NAME);
});
test("banned player cannot join a tournament of that organization", async ({
page,
factories,
}) => {
const player = await factories.UserFactory.create({
discordName: PLAYER_NAME,
});
const org = await factories.TournamentOrganizationFactory.create({
ownerId: ADMIN_ID,
name: ORGANIZATION_NAME,
});
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
organizationId: org.id,
startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
});
const organization = new OrganizationPage(page);
const tournamentPage = new TournamentPage(page);
// 1. As admin, ban the player from the organization
await impersonate(page, ADMIN_ID);
await organization.goto(org.slug);
await organization.openBannedUsers();
const banModal = await organization.openBanModal();
await expect(banModal.locators.dialog).toBeVisible();
await banModal.selectUser(PLAYER_NAME);
await banModal.form.fill("privateNote", "Test reason");
// a future expiration date to avoid validation issues
await banModal.form.setDateTime("expiresAt", addHours(new Date(), 24));
await banModal.save();
await expect(organization.locators.bannedUsersTable).toContainText(
"Test reason",
);
// 2. As the banned user, try to join a tournament
await impersonate(page, player.id);
await tournamentPage.goto(tournament.id);
const register = await tournamentPage.register();
await register.form.fill("pickUpName", "Banned Team");
await waitForPOSTResponse(page, () => register.form.submit());
// "Fill roster" only appears after a successful registration
await expect(register.locators.fillRosterHeading).not.toBeVisible();
// 3. As admin, remove the ban
await impersonate(page, ADMIN_ID);
await organization.goto(org.slug);
await organization.openBannedUsers();
await organization.unban();
// 4. As the unbanned user, verify they can now join a tournament
await impersonate(page, player.id);
await tournamentPage.goto(tournament.id);
const registerAgain = await tournamentPage.register();
const teamsTab = tournamentPage.nav.locators.teamsTab;
await expect(teamsTab).toContainText("Teams (0)");
await registerAgain.form.fill("pickUpName", "Unbanned Team");
await registerAgain.form.submit();
await expect(teamsTab).toContainText("Teams (1)");
// 5. As admin, ban user again but with permanent ban this time
await impersonate(page, ADMIN_ID);
await organization.goto(org.slug);
await organization.openBannedUsers();
const permanentBanModal = await organization.openBanModal();
await expect(permanentBanModal.locators.dialog).toBeVisible();
await permanentBanModal.selectUser(PLAYER_NAME);
await permanentBanModal.form.fill("privateNote", "Does not expire");
// expiresAt left empty makes the ban permanent
await permanentBanModal.save();
await expect(organization.locators.bannedUsersTable).toContainText(
"Permanent",
);
});
test("allows member of established org to create tournament", async ({
page,
factories,
}) => {
const orgAdmin = await factories.UserFactory.create();
const org = await factories.TournamentOrganizationFactory.create(
{ ownerId: ADMIN_ID, name: ORGANIZATION_NAME },
{ members: [{ userId: orgAdmin.id, role: "ADMIN" }] },
);
const newTournament = new CalendarNewEventPage(page);
await impersonate(page, orgAdmin.id);
await newTournament.gotoNewTournament();
await expect(
newTournament.locators.noTournamentPermissionsAlert,
).toBeVisible();
await expect(newTournament.locators.newTournamentHeading).not.toBeVisible();
await impersonate(page, ADMIN_ID);
const organization = new OrganizationPage(page);
await organization.goto(org.slug);
await organization.establish();
await impersonate(page, orgAdmin.id);
await newTournament.gotoNewTournament();
await expect(
newTournament.locators.noTournamentPermissionsAlert,
).not.toBeVisible();
await expect(newTournament.locators.newTournamentHeading).toBeVisible();
});
});

View File

@@ -0,0 +1,87 @@
import type { Locator, Page } from "@playwright/test";
import type { Ability } from "~/modules/in-game-lists/types";
import { ANALYZER_URL, weaponParamsPage } from "~/utils/urls";
import { expect, navigate, selectWeapon } from "../../helpers/playwright";
import { BuildFormPage } from "../builds/build-form-page";
import { WeaponParamsPage } from "./weapon-params-page";
type AnalyzerTab = "build1" | "build2" | "ap";
export class AnalyzerPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
newBuildPrompt: page.getByTestId("new-build-prompt"),
abilitySelector: page.getByTestId("ability-selector"),
rawParametersLink: page.getByRole("link", { name: /Raw parameters/ }),
};
}
async goto() {
await navigate({ page: this.page, url: ANALYZER_URL });
}
statCard(testId: string) {
return new AnalyzerStatCard(this.page, testId);
}
selectedAbility(ability: Ability) {
return this.locators.abilitySelector.getByTestId(`${ability}-ability`);
}
apComparison(buildNumber: 1 | 2) {
return this.page.getByTestId(`ap-compare-${buildNumber}`);
}
async selectWeapon(name: string) {
await selectWeapon({ page: this.page, name });
}
async openStatCategory(testId: string) {
await this.page.getByTestId(testId).click();
}
async selectTab(tab: AnalyzerTab) {
await this.page.getByTestId(`${tab}-tab`).click();
}
async addAbility(ability: Ability) {
await this.page.getByTestId(`${ability}-ability-button`).click();
}
async openNewBuildPrompt() {
await this.locators.newBuildPrompt.click();
return new BuildFormPage(this.page);
}
/** Selecting a weapon updates the link's href asynchronously, so wait for it before clicking */
async openRawParameters(weaponSlug: string) {
await expect(this.locators.rawParametersLink).toHaveAttribute(
"href",
new RegExp(weaponParamsPage(weaponSlug)),
);
await this.locators.rawParametersLink.click();
return new WeaponParamsPage(this.page);
}
}
class AnalyzerStatCard {
readonly root: Locator;
private readonly testId: string;
constructor(page: Page, testId: string) {
this.testId = testId;
this.root = page.getByTestId(testId);
}
get baseValue() {
return this.root.getByTestId(`${this.testId}-base`);
}
get buildValueTitle() {
return this.root.getByTestId(`${this.testId}-build-title`);
}
}

View File

@@ -0,0 +1,77 @@
import type { Locator, Page } from "@playwright/test";
import { weaponParamsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class WeaponParamsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
weaponHeaders: page.locator("th[class*='weaponHeader']"),
hideWeaponButtons: page.locator("[data-testid^='hide-weapon-']"),
showAllWeaponsButton: page.getByTestId("show-all-weapons"),
compareParamButtons: page.getByTestId("compare-param"),
comparisonDialog: page.getByRole("dialog"),
comparisonBars: page
.getByRole("dialog")
.locator("[class*='bars'] [class*='row']"),
expandableRows: page.locator("[class*='expandableRow']"),
patchHistoryTab: page.getByRole("tab", { name: /Patch history/ }),
patchColumns: page.locator("[class*='column']"),
subAndSpecialChangesSwitch: page.getByRole("switch", {
name: /Show sub & special changes/,
}),
};
}
async goto(weaponSlug: string) {
await navigate({ page: this.page, url: weaponParamsPage(weaponSlug) });
}
historyRow(nth: number) {
return new ParamHistoryRow(this.locators.expandableRows.nth(nth));
}
async hideWeapon(nth = 0) {
await this.locators.hideWeaponButtons.nth(nth).click();
}
async showAllWeapons() {
await this.locators.showAllWeaponsButton.click();
}
async openParamComparison(nth = 0) {
await this.locators.compareParamButtons.nth(nth).click();
}
async closeParamComparison() {
await this.page.keyboard.press("Escape");
}
async openPatchHistoryTab() {
await this.locators.patchHistoryTab.click();
}
async toggleSubAndSpecialChanges() {
await this.locators.subAndSpecialChangesSwitch.click({ force: true });
}
}
class ParamHistoryRow {
readonly root: Locator;
constructor(root: Locator) {
this.root = root;
}
/** Shown while the row is collapsed, hidden once expanded */
get historyBadge() {
return this.root.locator("[class*='historyBadge']").first();
}
async toggle() {
await this.root.locator("td[class*='paramName']").click();
}
}

View File

@@ -0,0 +1,52 @@
import type { Page } from "@playwright/test";
import type { ApiTokenType } from "~/features/api/api-types";
import { API_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
const HEADINGS: Record<ApiTokenType, string> = {
read: "Read Token",
write: "Write Token",
};
/** Where a user with API access generates their tokens. */
export class ApiPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
noAccessMessage: page.getByText("You do not have access to the API"),
// the revealed token is shown in a popover, rendered outside its section
tokenInput: page.locator("input[readonly]"),
};
}
async goto() {
await navigate({ page: this.page, url: API_PAGE });
}
async generateToken(type: ApiTokenType) {
await this.section(type)
.getByRole("button", { name: "Generate Token" })
.click();
await this.page.waitForURL(API_PAGE);
return this.revealToken(type);
}
async revealToken(type: ApiTokenType) {
await this.section(type)
.getByRole("button", { name: /reveal/i })
.click();
return this.locators.tokenInput.inputValue();
}
/** Both token sections look the same, only their heading tells them apart. */
private section(type: ApiTokenType) {
return this.page
.locator("div:has(> div > h2)")
.filter({ hasText: HEADINGS[type] });
}
}

View File

@@ -0,0 +1,40 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { artFormSchema } from "~/features/art/art-schemas";
import { newArtPage } from "~/features/art/art-urls";
import { navigate } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { UserArtPage } from "./user-art-page";
/** `/art/new` */
export class NewArtPage {
private readonly page: Page;
readonly locators;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, artFormSchema);
this.locators = {
fileInput: this.page.locator('input[type="file"]'),
preview: this.page.locator("form img"),
existingImage: this.page.locator('form img[src*="-small."]'),
descriptionInput: this.page.getByLabel(this.form.getLabel("description")),
};
}
/** Given an art id, edits that art instead of uploading a new one. */
async goto(artId?: Tables["Art"]["id"]) {
await navigate({ page: this.page, url: newArtPage(artId) });
}
async selectImage(filePath: string) {
await this.locators.fileInput.setInputFiles(filePath);
}
/** Lands on the uploader's own art page. */
async save() {
await this.form.submit();
return new UserArtPage(this.page);
}
}

View File

@@ -0,0 +1,30 @@
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";
/** `/u/:id/art` */
export class UserArtPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
images: this.page.getByTestId("art-image"),
pendingApprovalText: this.page.getByText(/pending moderator approval/i),
};
}
async goto(discordId: string) {
await navigate({ page: this.page, url: userArtPage({ discordId }) });
}
image(nth: number) {
return this.locators.images.nth(nth);
}
editLink(artId: Tables["Art"]["id"]) {
return this.page.locator(`a[href="${newArtPage(artId)}"]`);
}
}

View File

@@ -0,0 +1,59 @@
import type { Page } from "@playwright/test";
import { associationsPage } from "~/features/associations/associations-urls";
import invariant from "~/utils/invariant";
import {
modalClickConfirmButton,
navigate,
submit,
} from "../../helpers/playwright";
export class AssociationsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
deleteButtons: page.getByTestId("delete-association"),
leaveButton: page.getByTestId("leave-team-button"),
inviteLinkInputs: page.getByLabel("Share link to add members"),
};
}
async goto() {
await navigate({ page: this.page, url: associationsPage() });
}
async gotoInvite(inviteCode: string) {
await navigate({ page: this.page, url: associationsPage(inviteCode) });
}
heading(name: string) {
return this.page.getByRole("heading").filter({ hasText: name });
}
/** The share link is shown as the whole production url, only its code is of use here. */
async inviteCode() {
const inviteLink = await this.locators.inviteLinkInputs
.first()
.inputValue();
const inviteCode = new URL(inviteLink).searchParams.get("inviteCode");
invariant(inviteCode, `No invite code in the share link: ${inviteLink}`);
return inviteCode;
}
async join() {
await submit(this.page);
}
async deleteFirst() {
await this.locators.deleteButtons.first().click();
await modalClickConfirmButton(this.page);
}
async leave() {
await this.locators.leaveButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from "@playwright/test";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { newAssociationsPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class NewAssociationPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, createNewAssociationSchema);
}
async goto() {
await navigate({ page: this.page, url: newAssociationsPage() });
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,66 @@
import type { Page } from "@playwright/test";
import { badgePage } from "~/utils/urls";
import {
navigate,
selectUser,
waitForPOSTResponse,
} from "../../helpers/playwright";
/** `/badges/:id` */
export class BadgePage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
editLink: this.page.getByRole("link", { name: "Edit", exact: true }),
owners: this.page.getByTestId("badge-owners"),
};
}
async goto(badgeId: number) {
await navigate({ page: this.page, url: badgePage(badgeId) });
}
owner(username: string) {
return this.locators.owners
.getByRole("listitem")
.filter({ hasText: username });
}
async openEdit() {
await this.locators.editLink.click();
return new BadgeEditDialog(this.page);
}
}
class BadgeEditDialog {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
dialog: this.page.getByRole("dialog"),
submitButton: this.page.getByRole("button", {
name: "Submit",
exact: true,
}),
};
}
async addOwner(userName: string) {
await selectUser({
page: this.page,
userName,
labelName: "Add new owner",
});
}
async save() {
await waitForPOSTResponse(this.page, () =>
this.locators.submitButton.click(),
);
}
}

View File

@@ -0,0 +1,91 @@
import type { Locator, Page } from "@playwright/test";
import { ADMIN_PAGE } from "~/utils/urls";
import {
navigate,
selectUser,
waitForPOSTResponse,
} from "../../helpers/playwright";
/** The ban & unban forms of the admin page. */
export class AdminBanPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
banForm: page
.locator("form")
.filter({ has: page.locator("h2", { hasText: /^Ban user$/ }) }),
unbanForm: page
.locator("form")
.filter({ has: page.locator("h2", { hasText: /^Unban user$/ }) }),
};
}
async goto() {
await navigate({ page: this.page, url: ADMIN_PAGE });
}
async banUser(
userName: string,
options: { expiresAt?: Date; reason?: string } = {},
) {
const form = this.locators.banForm;
await selectUser({
page: this.page,
userName,
labelName: "User",
within: form,
});
if (options.expiresAt) {
await this.fillExpiresAt(options.expiresAt);
}
if (options.reason) {
await form.getByLabel("Reason").fill(options.reason);
}
await this.save(form);
}
async unbanUser(userName: string) {
const form = this.locators.unbanForm;
await selectUser({
page: this.page,
userName,
labelName: "User",
within: form,
});
await this.save(form);
}
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");
}
private async save(form: Locator) {
await waitForPOSTResponse(this.page, () =>
form.getByRole("button", { name: "Save" }).click(),
);
}
}

View File

@@ -0,0 +1,20 @@
import type { Page } from "@playwright/test";
/** Where a banned user ends up no matter which page they try to visit. */
export class SuspendedPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
heading: page.getByText("Account suspended"),
endsAt: page.getByText("Ends:"),
noEndTime: page.getByText("no end time set"),
};
}
reason(reason: string) {
return this.page.getByText(`Reason: ${reason}`);
}
}

View File

@@ -0,0 +1,21 @@
import type { Locator } from "@playwright/test";
export class BuildCard {
readonly root: Locator;
constructor(root: Locator) {
this.root = root;
}
get title() {
return this.root.getByTestId("build-title");
}
weaponImage(weaponName: string) {
return this.root.getByAltText(weaponName);
}
modeImage(modeName: string) {
return this.root.getByAltText(modeName);
}
}

View File

@@ -0,0 +1,47 @@
import type { Page } from "@playwright/test";
import { newBuildBaseSchema } from "~/features/user-page/user-page-schemas";
import { userNewBuildPage } from "~/features/user-page/user-page-urls";
import type { Ability, GearType } from "~/modules/in-game-lists/types";
import { navigate } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class BuildFormPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, newBuildBaseSchema);
}
async gotoNew(discordId: string) {
await navigate({ page: this.page, url: userNewBuildPage({ discordId }) });
}
gearSelect(type: GearType) {
return this.page.getByTestId(`${type}-gear-select`);
}
ability(ability: Ability) {
return this.page.getByTestId(`${ability}-ability`);
}
weaponPoolItem(weaponName: string) {
return this.page.getByRole("listitem").getByText(weaponName);
}
async selectGear(type: GearType, name: string) {
await this.gearSelect(type).click();
await this.page.getByPlaceholder("Search gear...").fill(name);
await this.page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`gear-select-option-${name}`)
.click();
}
async addAbility(ability: Ability, times = 1) {
for (let i = 0; i < times; i++) {
await this.page.getByTestId(`${ability}-ability-button`).click();
}
}
}

View File

@@ -0,0 +1,25 @@
import type { Page } from "@playwright/test";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { BUILDS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class BuildsPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await navigate({ page: this.page, url: BUILDS_PAGE });
}
weaponLink(weaponSplId: MainWeaponId) {
return this.page.getByTestId(`weapon-${weaponSplId}-link`);
}
async openWeapon(weaponSplId: MainWeaponId) {
await this.goto();
await this.weaponLink(weaponSplId).click();
}
}

View File

@@ -0,0 +1,44 @@
import type { Page } from "@playwright/test";
import invariant from "~/utils/invariant";
import { userBuildsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { BuildCard } from "./build-card";
import { BuildFormPage } from "./build-form-page";
export class UserBuildsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
buildsTab: page.getByTestId("user-builds-tab"),
changeSortingButton: page.getByTestId("change-sorting-button"),
buildCards: page.getByTestId("build-card"),
editBuildLinks: page.getByTestId("edit-build"),
};
}
async goto(discordId: string) {
await navigate({ page: this.page, url: userBuildsPage({ discordId }) });
}
buildCard(nth: number) {
return new BuildCard(this.locators.buildCards.nth(nth));
}
async buildId(nth: number) {
const href = await this.locators.editBuildLinks
.nth(nth)
.getAttribute("href");
invariant(href, "edit-build link missing href");
const match = href.match(/buildId=(\d+)/);
invariant(match, `buildId not found in href: ${href}`);
return Number(match[1]);
}
async editBuild(nth: number) {
await this.locators.editBuildLinks.nth(nth).click();
return new BuildFormPage(this.page);
}
}

View File

@@ -0,0 +1,62 @@
import type { Page } from "@playwright/test";
import type { Ability, ModeShort } from "~/modules/in-game-lists/types";
import { weaponBuildPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { BuildCard } from "./build-card";
export class WeaponBuildsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
buildCards: page.getByTestId("build-card"),
addFilterButton: page.getByTestId("add-filter-button"),
comparisonSelect: page.getByTestId("comparison-select"),
dateSelect: page.getByTestId("date-select"),
dateInput: page.getByTestId("date-input"),
};
}
async goto(weaponSlug: string) {
await navigate({ page: this.page, url: weaponBuildPage(weaponSlug) });
}
buildCard(nth: number) {
return new BuildCard(this.locators.buildCards.nth(nth));
}
ability(ability: Ability) {
return this.page.getByTestId(`${ability}-ability`);
}
modeBadge(mode: ModeShort) {
return this.page.getByTestId(`build-mode-${mode}`);
}
modeFilterCheckbox(modeName: string) {
return this.page.getByLabel(modeName);
}
async addFilter(type: "ability" | "mode" | "date") {
await this.page.keyboard.press("Escape");
await this.locators.addFilterButton.click();
await this.page.getByTestId(`menu-item-${type}`).click();
if (type === "ability") {
await this.page.getByTestId("add-ability-condition").click();
}
}
async deleteFilter(type: "ability" | "mode" | "date") {
if (type === "ability") {
await this.page.getByTestId("delete-ability-condition").click();
return;
}
await this.page.keyboard.press("Escape");
await this.page.getByTestId(`${type}-remove`).click();
}
}

View File

@@ -0,0 +1,25 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { calendarEventPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/calendar/:id` */
export class CalendarEventPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
resultRows: page.getByRole("row"),
};
}
async goto(eventId: Tables["CalendarEvent"]["id"]) {
await navigate({ page: this.page, url: calendarEventPage(eventId) });
}
resultRow(teamName: string) {
return this.locators.resultRows.filter({ hasText: teamName });
}
}

View File

@@ -0,0 +1,136 @@
import type { Page } from "@playwright/test";
import { calendarNewBaseSchema } from "~/features/calendar/calendar-new-schemas";
import { CALENDAR_NEW_PAGE, TOURNAMENT_NEW_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** `/calendar/new`, also used for adding tournaments and editing existing events. */
export class CalendarNewEventPage {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, calendarNewBaseSchema);
this.locators = {
nameInput: page.getByLabel(/^Name *\*?$/),
newTournamentHeading: page.getByText("New tournament"),
noTournamentPermissionsAlert: page.getByText(
"No permissions to add tournaments",
),
addBracketButton: page.getByTestId("brackets-add-item-button"),
bracketNameInputs: page.getByLabel(/^Bracket name *\*?$/),
bracketFormatSelects: page.getByLabel("Format"),
placementsInputs: page.getByLabel("Placements"),
deleteBracketButtons: page.getByTestId("brackets-remove-item-button"),
signUpSourceRadios: page.getByRole("radio", { name: "Sign-up" }),
// the sources array is nested inside a progression item, so its add button
// test id is prefixed by the item's path e.g. "progression[1].sources"
addSourceButtons: page.locator(
'[data-testid$="sources-add-item-button"]',
),
mapPoolTemplateSelect: page.getByLabel("Template"),
clearMapPoolButton: page.getByRole("button", { name: "Clear" }),
};
}
async goto() {
await navigate({ page: this.page, url: CALENDAR_NEW_PAGE });
}
async gotoNewTournament() {
await navigate({ page: this.page, url: TOURNAMENT_NEW_PAGE });
}
// the `date` datetime inputs use the array item's label ("Date"), not the
// array's own label, so they're driven directly rather than via the form helper
async setFirstDate(date: Date) {
const fill = (segment: string, value: string) =>
this.page
.getByRole("spinbutton", { name: new RegExp(`^${segment}, Date`) })
.first()
.fill(value);
const hours = date.getHours();
await fill("year", String(date.getFullYear()));
await fill("month", String(date.getMonth() + 1));
await fill("day", String(date.getDate()));
await fill("hour", String(hours % 12 || 12));
await fill("minute", date.getMinutes().toString().padStart(2, "0"));
await fill("AM/PM", hours >= 12 ? "PM" : "AM");
}
// the TO map pool grid exposes each map as a mode button inside a group labelled
// by its stage name
async pickMapPool(maps: Array<{ stage: string; mode: string }>) {
for (const { stage, mode } of maps) {
await this.page
.getByRole("group", { name: stage })
.getByRole("button", { name: mode })
.click();
}
}
async deleteLastBracket() {
await this.locators.deleteBracketButtons.last().click();
}
async fillLastPlacements(placements: string) {
await this.locators.placementsInputs.last().fill(placements);
}
async setBracketFormat(nth: number, formatLabel: string) {
await this.locators.bracketFormatSelects.nth(nth).selectOption(formatLabel);
}
/** Selects the "Sign-up" source for every bracket, making them all starting brackets. */
async makeAllBracketsStartingBrackets() {
for (const radio of await this.locators.signUpSourceRadios.all()) {
if (await radio.isEnabled()) {
await radio.check();
}
}
}
async clearMapPool() {
await this.locators.clearMapPoolButton.click();
}
async selectMapPoolTemplate(value: string) {
await this.locators.mapPoolTemplateSelect.selectOption(value);
}
save() {
return submit(this.page);
}
// a freshly added bracket is already a follow-up (sourcing from the first
// bracket), so it only needs its name, format and source placements filled in
async addFollowUpBracket({
name,
format,
placements,
}: {
name: string;
format: string;
placements: string;
}) {
await this.locators.addBracketButton.click();
await this.locators.bracketNameInputs.last().fill(name);
await this.locators.bracketFormatSelects.last().selectOption(format);
await this.locators.placementsInputs.last().fill(placements);
}
async renameBracket(nth: number, name: string) {
await this.locators.bracketNameInputs.nth(nth).fill(name);
}
/** Adds another source bracket to the last bracket of the progression. The new
* row preselects the first bracket not sourced by it yet. */
async addSourceToLastBracket(placements: string) {
await this.locators.addSourceButtons.last().click();
await this.locators.placementsInputs.last().fill(placements);
}
}

View File

@@ -0,0 +1,101 @@
import type { Page } from "@playwright/test";
import { calendarPage } from "~/features/calendar/calendar-urls";
import {
expectIsHydrated,
navigate,
waitForPOSTResponse,
} from "../../helpers/playwright";
/** `/calendar` */
export class CalendarPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
tournamentCards: page.getByTestId("tournament-card"),
hiddenEventsButtons: page.getByTestId("hidden-events-button"),
clockHeaderTimes: page.getByTestId("clock-header-time"),
todayHeader: page.getByTestId("today-header"),
eventTypeFilterPill: page.getByTestId("event-type-filter"),
addFilterButton: page.getByTestId("add-filter-button"),
saveFiltersAsDefaultButton: page.getByTestId(
"save-filters-as-default-button",
),
navigateButtons: page.getByTestId("calendar-navigate-button"),
};
}
async goto() {
await navigate({ page: this.page, url: calendarPage() });
}
tournamentCard(name: string) {
return this.locators.tournamentCards.filter({ hasText: name });
}
tentativeTierPill(tournamentName: string) {
return this.tournamentCard(tournamentName).getByTestId("tentative-tier");
}
confirmedTierPill(tournamentName: string) {
return this.tournamentCard(tournamentName).getByTestId("confirmed-tier");
}
async reload() {
await this.page.reload();
await expectIsHydrated(this.page);
}
/** Toggles one of the switches inside the "Event type" filter pill's popover. */
async toggleEventTypeFilter(name: "isSendou" | "isRanked") {
await this.page.keyboard.press("Escape");
await this.openEventTypeFilter();
await this.page
.getByText(
name === "isSendou"
? "Only events hosted on sendou.ink"
: "Only ranked events",
)
.click();
await this.page.keyboard.press("Escape");
}
/** Resets the "Event type" filter pill's filters, hiding the pill. */
async removeEventTypeFilter() {
await this.page.keyboard.press("Escape");
await this.page.getByTestId("event-type-filter-remove").click();
}
/** The pill is only rendered while its filters differ from the defaults. */
private async openEventTypeFilter() {
if (await this.locators.eventTypeFilterPill.isVisible()) {
await this.locators.eventTypeFilterPill.click();
return;
}
await this.locators.addFilterButton.click();
await this.page.getByTestId("menu-item-event-type-filter").click();
}
/** Persists the current filters as the user's default. */
async saveFiltersAsDefault() {
await waitForPOSTResponse(this.page, () =>
this.locators.saveFiltersAsDefaultButton.click(),
);
}
/** Shows or hides the events the current filters hide, of the first time slot. */
async toggleHiddenEvents() {
await this.locators.hiddenEventsButtons.first().click();
}
async navigatePrevious() {
await this.locators.navigateButtons.first().click();
}
async navigateNext() {
await this.locators.navigateButtons.nth(1).click();
}
}

View File

@@ -0,0 +1,43 @@
import type { Locator, Page } from "@playwright/test";
import { EVENTS_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
const VIEW_LABELS = {
registered: "Registered",
hosting: "Hosting",
scrims: "Scrims",
saved: "Saved",
organization: "Organization",
};
/** `/events` */
export class EventsPage {
private readonly page: Page;
private readonly main: Locator;
readonly locators;
constructor(page: Page) {
this.page = page;
this.main = page.locator("main");
this.locators = {
title: page.getByRole("heading", { name: "My Events" }),
viewTabs: this.main.getByRole("navigation"),
emptyCategoryText: page.getByText("No events in this category"),
};
}
async goto() {
await navigate({ page: this.page, url: EVENTS_PAGE });
}
/** The tabs carry the category's event count, so they are matched by their start. */
async openView(view: keyof typeof VIEW_LABELS) {
await this.locators.viewTabs
.getByRole("link", { name: new RegExp(`^${VIEW_LABELS[view]}`) })
.click();
}
eventLink(name: string) {
return this.main.getByRole("link", { name });
}
}

View File

@@ -0,0 +1,55 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { calendarReportWinnersPage } from "~/utils/urls";
import { navigate, selectUser, submit } from "../../helpers/playwright";
/** `/calendar/:id/report-winners` */
export class ReportWinnersPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
participantCountInput: page.getByLabel("Participant count"),
teamNameInput: page.getByLabel("Team name"),
placingInput: page.getByLabel("Placing"),
emptyTeamError: page.getByText(
"Each team must have at least one player.",
),
submitButton: page.getByTestId("submit-button"),
};
}
async goto(eventId: Tables["CalendarEvent"]["id"]) {
await navigate({
page: this.page,
url: calendarReportWinnersPage(eventId),
});
}
player(number: number) {
return this.page.getByLabel(`Player ${number}`);
}
async selectPlayer(number: number, userName: string) {
await selectUser({
page: this.page,
userName,
labelName: `Player ${number}`,
});
}
/** Players without a sendou.ink account are reported as plain text instead. */
async fillPlayerAsText(number: number, name: string) {
await this.page
.getByRole("button", { name: "Add as text" })
.nth(number - 1)
.click();
await this.player(number).fill(name);
}
async submit() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,98 @@
import type { Locator, Page } from "@playwright/test";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { COMP_ANALYZER_URL } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
type Categorization = "category" | "sub" | "special";
export class CompAnalyzerPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
selectedWeapons: page.getByTestId("selected-weapons"),
emptyWeaponSlots: page.getByText("Pick a weapon"),
weaponGridToggle: page.getByTestId("weapon-grid-toggle"),
categorizationToggle: page.getByTestId("categorization-toggle"),
rangeVisualization: page.getByTestId("range-visualization"),
};
}
async goto() {
await navigate({ page: this.page, url: COMP_ANALYZER_URL });
}
get damageCombos() {
return new DamageComboList(this.page.getByTestId("damage-combo-list"));
}
selectedWeapon(index: number) {
return this.page.getByTestId(`selected-weapon-${index}`);
}
categorizationRadio(categorization: Categorization) {
return this.page.getByTestId(`categorization-${categorization}`);
}
async selectWeapon(weaponSplId: MainWeaponId) {
await this.page.getByTestId(`weapon-button-${weaponSplId}`).click();
}
async removeWeapon(index: number) {
await this.page.getByTestId(`remove-weapon-${index}`).click();
}
async selectCategorization(categorization: Categorization) {
await this.categorizationRadio(categorization).click();
}
async toggleWeaponGrid() {
await this.locators.weaponGridToggle.click();
}
}
class DamageComboList {
readonly root: Locator;
constructor(root: Locator) {
this.root = root;
}
get content() {
return this.root.locator(".content, [class*='content']");
}
get damageValues() {
return this.root.locator("[class*='damageValue']");
}
get inkTimes() {
return this.root.locator("[class*='inkTime']");
}
get damageTypeLabels() {
return this.root.locator("[class*='damageTypeLabel']");
}
get filteredItems() {
return this.root.locator("button[class*='filteredItem']");
}
get subDefenseSlider() {
return this.sliders.first();
}
get inkResistanceSlider() {
return this.sliders.nth(1);
}
async toggleCollapsed() {
await this.root.getByTestId("damage-combo-toggle").click();
}
private get sliders() {
return this.root.locator("input[type='range']");
}
}

View File

@@ -0,0 +1,75 @@
import type { Locator, Page } from "@playwright/test";
import { sendFriendRequestBaseSchema } from "~/features/friends/friends-schemas";
import { FRIENDS_PAGE } from "~/utils/urls";
import {
navigate,
selectUser,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** `/friends` */
export class FriendsPage {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, sendFriendRequestBaseSchema);
this.locators = {
acceptButton: this.page.getByRole("button", { name: "Accept" }),
cancelRequestButton: this.page.getByRole("button", { name: "Cancel" }),
noFriendsText: this.page.getByText("No friends yet"),
};
}
async goto() {
await navigate({ page: this.page, url: FRIENDS_PAGE });
}
async sendRequest(userName: string) {
await selectUser({
page: this.page,
userName,
labelName: this.form.getLabel("userId"),
});
await submit(this.page);
}
/** Accepts the first pending request, the newest one. */
async acceptRequest() {
await waitForPOSTResponse(this.page, () =>
this.locators.acceptButton.first().click(),
);
}
friendButton(name: string) {
return this.page.getByRole("button", { name });
}
friend(name: string) {
return new FriendMenu(this.page, name);
}
}
class FriendMenu {
private readonly page: Page;
private readonly trigger: Locator;
constructor(page: Page, name: string) {
this.page = page;
// scoped to the page content because the sidebar's friends section shows
// a button with the same name once the friendship data refreshes
this.trigger = page.getByRole("main").getByRole("button", { name });
}
async deleteFriend() {
await this.trigger.click();
await this.page.getByText("Delete friend").click();
await waitForPOSTResponse(this.page, () =>
this.page.getByRole("button", { name: "Delete" }).click(),
);
}
}

View File

@@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
import { navigate } from "../../helpers/playwright";
import { WelcomePage } from "../user/welcome-page";
/** `/` */
export class FrontPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
welcomeBanner: page.getByRole("link", {
name: "New to competitive Splatoon? Start here!",
}),
};
}
async goto() {
await navigate({ page: this.page, url: "/" });
}
async openWelcomeBanner() {
await this.locators.welcomeBanner.click();
return new WelcomePage(this.page);
}
}

View File

@@ -0,0 +1,29 @@
import type { Page } from "@playwright/test";
import { navigate } from "../../helpers/playwright";
const IMAGE_VALIDATION_PAGE = "/upload/admin";
/** `/upload/admin`, where an admin approves user submitted images. */
export class ImageValidationPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
images: this.page.locator("main img"),
approveAllButton: this.page.getByRole("button", {
name: /All .* above ok/,
}),
allValidatedText: this.page.getByText("All validated!"),
};
}
async goto() {
await navigate({ page: this.page, url: IMAGE_VALIDATION_PAGE });
}
async approveAll() {
await this.locators.approveAllButton.click();
}
}

View File

@@ -0,0 +1,32 @@
import type { Page } from "@playwright/test";
type AddableItem =
| "art"
| "association"
| "builds"
| "calendarEvent"
| "lfgPost"
| "organization"
| "plus"
| "scrimPost"
| "team"
| "tournament"
| "vods";
/** The "Add new…" menu of the site layout, available on every page. */
export class AnythingAdder {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
menuButton: page.getByTestId("anything-adder-menu-button").first(),
};
}
async add(item: AddableItem) {
await this.locators.menuButton.click();
await this.page.getByTestId(`menu-item-${item}`).click();
}
}

View File

@@ -0,0 +1,77 @@
import type { Locator, Page } from "@playwright/test";
import { FriendRow } from "./side-nav";
type Panel = "menu" | "friends" | "tourneys" | "chat" | "you";
const TAB_NAMES: Record<Panel, string> = {
menu: "Menu",
friends: "Friends",
tourneys: "Events",
chat: "Chat",
you: "You",
};
const PANELS: Panel[] = ["menu", "friends", "tourneys", "chat", "you"];
/**
* The bottom tab bar and its panels, rendered in place of the side nav on mobile.
*
* The panels show the same rows as the side nav does, and its accessors find them
* the same way — scoped to the panel that is open.
*/
export class MobileNav {
private readonly page: Page;
private readonly openPanelDialog: Locator;
readonly locators;
constructor(page: Page) {
this.page = page;
this.openPanelDialog = page.locator("[class*='panelDialog']:visible");
this.locators = {
menuPanel: page.getByLabel("Menu", { exact: true }),
streamsHeading: page.locator("h3").filter({ hasText: "Streams" }),
viewAllLink: page.getByRole("link", { name: "View all", exact: true }),
youPanelUsername: page.locator("[class*='youPanelUsername']"),
friendItems: this.openPanelDialog.locator("button[class*='listButton']"),
};
}
tab(panel: Panel) {
return this.page.getByRole("button", { name: TAB_NAMES[panel] });
}
async openPanel(panel: Panel) {
await this.tab(panel).click();
}
/**
* Switches between panels the way the tab bar does while a panel is open. Its
* tabs are then covered by invisible overlays, which only a dispatched event reaches.
*/
async switchPanel(panel: Panel) {
await this.page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(PANELS.indexOf(panel))
.dispatchEvent("click");
}
async closePanel() {
await this.page.locator("button:has(svg.lucide-x)").first().click();
}
menuLink(name: string) {
return this.locators.menuPanel.getByRole("link", { name });
}
friend(name: string) {
return new FriendRow(this.page, this.openPanelDialog, name);
}
eventItem(name: string) {
return this.openPanelDialog.getByRole("link", { name });
}
streamItem(name: string) {
return this.eventItem(name);
}
}

View File

@@ -0,0 +1,43 @@
import type { Page } from "@playwright/test";
import { NotificationsPage } from "../notifications/notifications-page";
/** The notification bell of the layout, and the popover it opens. */
export class NotificationPopover {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
openButton: this.page.getByTestId("notifications-button"),
items: this.page.getByTestId("notification-item"),
seeAllLink: this.page.getByTestId("notifications-see-all-button"),
/** Shown on the bell while unseen notifications exist. */
bellDot: this.page.getByTestId("notifications-bell-dot"),
/** Per notification, marking it as one the user has not read yet. */
unseenDots: this.page.getByTestId("notification-unseen-dot"),
};
}
async open() {
await this.locators.openButton.click();
}
async close() {
await this.page.keyboard.press("Escape");
}
notification(text: string) {
return this.locators.items.filter({ hasText: text });
}
async openNotification(text: string) {
await this.notification(text).click();
}
/** Only offered once the popover's peek list is full. */
async openAll() {
await this.locators.seeAllLink.click();
return new NotificationsPage(this.page);
}
}

View File

@@ -0,0 +1,137 @@
import type { Locator, Page } from "@playwright/test";
import { waitForPOSTResponse } from "../../helpers/playwright";
type Section = "Events" | "Friends" | "Streams";
/**
* The sidebar of the site layout, a modal behind a hamburger on narrower viewports.
*
* Its rows are looked up inside the sidebar the viewport shows — the rail, or the
* copy of it the modal renders — so that the same row appearing on the page behind
* it (a tournament also shown as a front page card) is not mistaken for one.
*/
export class SideNav {
private readonly page: Page;
private readonly root: Locator;
readonly locators;
constructor(page: Page) {
this.page = page;
this.root = page.locator("nav[class*='sideNav']:visible");
this.locators = {
collapseButton: page.getByTestId("sidenav-collapse-button"),
modalTrigger: page.getByTestId("sidenav-modal-trigger"),
unseenRequestsBadge: page.getByRole("status", {
name: /unseen friend request/,
}),
upcomingDivider: this.root.locator("[class*='upcomingDivider']"),
/** Every row of the sidebar's link lists, in render order: events, then streams. */
listItems: this.root.locator("a[class*='listLink']"),
friendItems: this.root.locator("button[class*='listButton']"),
};
}
/** Where the unseen friend requests badge moves to while the sidebar is collapsed. */
get collapsedRequestsBadge() {
return this.locators.collapseButton.getByRole("status");
}
sectionHeading(section: Section) {
return this.root.getByRole("heading", { name: section });
}
viewAllLink(section: "Events" | "Friends") {
return this.sectionHeading(section)
.locator("xpath=..")
.getByRole("link", { name: /View all/ });
}
/** Text shown in place of a section's rows when it has none. */
emptyText(text: string) {
return this.page.locator("[class*='sideNavEmpty']:visible", {
hasText: text,
});
}
eventItem(name: string) {
return this.root.getByRole("link", { name });
}
streamItem(name: string) {
return this.eventItem(name);
}
itemSubtitle(name: string) {
return this.eventItem(name).locator("span[class*='listLinkSubtitle']");
}
itemBadge(name: string) {
return this.eventItem(name).locator("span[class*='listLinkBadge']");
}
/** The tier an upcoming tournament's stream row is pilled with. */
itemTier(name: string) {
return this.streamItem(name).getByTestId("confirmed-tier");
}
saveStreamButton(streamName: string) {
return this.streamItem(streamName).getByTitle("Save");
}
savedStreamIcon(streamName: string) {
return this.streamItem(streamName).locator("svg.lucide-bookmark-check");
}
async saveStream(streamName: string) {
await waitForPOSTResponse(this.page, () =>
this.saveStreamButton(streamName).click(),
);
}
friend(name: string) {
return new FriendRow(this.page, this.root, name);
}
async toggleCollapse() {
await waitForPOSTResponse(this.page, () =>
this.locators.collapseButton.click(),
);
}
async openModal() {
await this.locators.modalTrigger.click();
}
async closeModal() {
await this.page.keyboard.press("Escape");
}
}
/** One friend of the friends list, and the menu opening from it. */
export class FriendRow {
private readonly page: Page;
readonly trigger: Locator;
readonly locators;
constructor(page: Page, root: Locator, name: string) {
this.page = page;
this.trigger = root.getByRole("button", { name });
this.locators = {
subtitle: this.trigger.locator("span[class*='listLinkSubtitle']"),
badge: this.trigger.locator("span[class*='listLinkBadge']"),
};
}
async openMenu() {
await this.trigger.click();
}
/** Menu items render in a portal, so they are found on the page, not in the row. */
menuItem(name: string) {
return this.page.getByRole("menuitem", { name });
}
watchStreamHref() {
return this.menuItem("Watch stream").getAttribute("href");
}
}

View File

@@ -0,0 +1,30 @@
import type { Page } from "@playwright/test";
type Category = "Play" | "Tools" | "Community";
/** The category menus (Play, Tools, Community) of the site layout's header. */
export class TopNavMenus {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
// a closed menu renders the same links as an icon-only preview, so
// anything asserted about an open menu has to be scoped to it
openMenu: page.locator("[class*='menuContent']"),
};
}
async open(category: Category) {
await this.page.getByRole("button", { name: category }).click();
}
async close() {
await this.page.keyboard.press("Escape");
}
link(name: string) {
return this.locators.openMenu.getByRole("link", { name });
}
}

View File

@@ -0,0 +1,55 @@
import type { Page } from "@playwright/test";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { LFG_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { UserCard } from "../user/user-card";
import { NewLFGPostPage } from "./new-lfg-post-page";
export class LFGPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
addFilterButton: page.getByTestId("add-filter-button"),
languageFilterSelect: page.getByLabel("Spoken language", {
exact: true,
}),
};
}
async goto() {
await navigate({ page: this.page, url: LFG_PAGE });
}
post(text: string) {
return this.page.getByText(text);
}
/** The languages of a post, rendered as one pill e.g. "JA / KO". */
languagePill(languages: string) {
return this.page.getByText(languages, { exact: true });
}
openUserCard(name: string) {
return UserCard.open(
this.page,
this.page.getByRole("button", { name }).first(),
);
}
async editFirstPost() {
await this.page.getByRole("link", { name: "Edit" }).first().click();
return new NewLFGPostPage(this.page);
}
async addLanguageFilter() {
await this.locators.addFilterButton.click();
await this.page.getByRole("menuitem", { name: "Spoken language" }).click();
}
async selectFilterLanguage(code: UnifiedLanguageCode) {
await this.locators.languageFilterSelect.selectOption(code);
}
}

View File

@@ -0,0 +1,32 @@
import type { Page } from "@playwright/test";
import { lfgNewSchema } from "~/features/lfg/lfg-schemas";
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class NewLFGPostPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, lfgNewSchema);
}
async goto() {
await navigate({ page: this.page, url: lfgNewPostPage() });
}
/** The language checkboxes are labeled by their own name e.g. "日本語". */
checkLanguage(name: string) {
return this.page.getByLabel(name).check();
}
uncheckLanguage(name: string) {
return this.page.getByLabel(name).uncheck();
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,21 @@
import type { Page } from "@playwright/test";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/notifications` */
export class NotificationsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
heading: this.page.getByRole("heading", { name: "Notifications" }),
items: this.page.getByTestId("notification-item"),
};
}
async goto() {
await navigate({ page: this.page, url: NOTIFICATIONS_URL });
}
}

View File

@@ -0,0 +1,50 @@
import type { Page } from "@playwright/test";
import type { DamageReceiver } from "~/features/object-damage-calculator/calculator-types";
import { OBJECT_DAMAGE_CALCULATOR_URL } from "~/utils/urls";
import { navigate, selectWeapon } from "../../helpers/playwright";
export class ObjectDamageCalculatorPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
damageTypeSelect: page.locator("text=Damage type"),
abilityPointsSelect: page.locator("text=Amount of"),
multiplierSwitch: page.getByTestId("multi-switch"),
};
}
async goto() {
await navigate({ page: this.page, url: OBJECT_DAMAGE_CALCULATOR_URL });
}
hitPoints(receiver: DamageReceiver = "Chariot") {
return this.page.getByTestId(`hp-${receiver}`);
}
damage(receiver: DamageReceiver = "Chariot") {
return this.page.getByTestId(`dmg-${receiver}`);
}
hitsToDestroy(receiver: DamageReceiver = "Chariot") {
return this.page.getByTestId(`htd-${receiver}`);
}
async selectWeapon(name: string) {
await selectWeapon({ page: this.page, name });
}
async selectDamageType(damageType: string) {
await this.locators.damageTypeSelect.selectOption(damageType);
}
async selectAbilityPoints(abilityPoints: number) {
await this.locators.abilityPointsSelect.selectOption(String(abilityPoints));
}
async toggleMultiplier() {
await this.locators.multiplierSwitch.click();
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from "@playwright/test";
import { newOrganizationSchema } from "~/features/tournament-organization/tournament-organization-schemas";
import { ORGANIZATION_NEW_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class NewOrganizationPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, newOrganizationSchema);
}
async goto() {
await navigate({ page: this.page, url: ORGANIZATION_NEW_PAGE });
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import type { TournamentOrganizationRole } from "~/features/tournament-organization/tournament-organization-constants";
import { tournamentOrganizationEditPage } from "~/features/tournament-organization/tournament-organization-urls";
import { navigate, submit } from "../../helpers/playwright";
export class OrganizationEditPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
title: page.getByText("Editing tournament organization"),
};
}
async goto(organizationSlug: string) {
await navigate({
page: this.page,
url: tournamentOrganizationEditPage(organizationSlug),
});
}
/** The members array field renders one fieldset per member, identified by the selected user. */
memberFieldset(username: string) {
return this.page.locator(`fieldset:has(button:has-text("${username}"))`);
}
async setMemberRole(username: string, role: TournamentOrganizationRole) {
await this.memberFieldset(username)
.getByLabel("Role", { exact: true })
.selectOption(role);
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,96 @@
import type { Page } from "@playwright/test";
import {
banUserActionSchema,
updateIsEstablishedSchema,
} from "~/features/tournament-organization/tournament-organization-schemas";
import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls";
import {
modalClickConfirmButton,
navigate,
selectUser,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { OrganizationEditPage } from "./organization-edit-page";
export class OrganizationPage {
private readonly page: Page;
private readonly isEstablishedForm;
readonly locators;
constructor(page: Page) {
this.page = page;
this.isEstablishedForm = createFormHelpers(page, updateIsEstablishedSchema);
this.locators = {
editButton: page.getByTestId("edit-org-button"),
bannedUsersTab: page.getByTestId("banned-users-tab"),
adminTab: page.getByRole("tab", { name: "Admin" }),
newBanButton: page.getByRole("button", { name: "New ban" }),
unbanButton: page.getByRole("button", { name: "Unban" }),
bannedUsersTable: page.getByRole("table"),
};
}
async goto(organizationSlug: string) {
await navigate({
page: this.page,
url: tournamentOrganizationPage({ organizationSlug }),
});
}
async openEdit() {
await this.locators.editButton.click();
return new OrganizationEditPage(this.page);
}
/** Established organizations can add tournaments and their admins can edit them. */
async establish() {
await this.locators.adminTab.click();
await waitForPOSTResponse(this.page, () =>
this.isEstablishedForm.check("isEstablished"),
);
}
async openBannedUsers() {
await this.locators.bannedUsersTab.click();
}
async openBanModal() {
await this.locators.newBanButton.click();
return new BanUserDialog(this.page);
}
async unban() {
await this.locators.unbanButton.click();
await modalClickConfirmButton(this.page);
}
}
class BanUserDialog {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, banUserActionSchema);
this.locators = {
dialog: page.getByRole("dialog"),
};
}
/** Scoped to the dialog: the tab panel behind it carries the label too. */
async selectUser(userName: string) {
await selectUser({
page: this.page,
userName,
labelName: "Player",
within: this.locators.dialog,
});
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,70 @@
import type { Page } from "@playwright/test";
import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas";
import { newScrimPostPage } from "~/utils/urls";
import {
navigate,
selectTournament,
selectUser,
submit,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class NewScrimPostPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, scrimsNewFormSchema);
}
async goto() {
await navigate({ page: this.page, url: newScrimPostPage() });
}
/** Posts as a pick-up instead of as a team, the author being its first user. */
async selectPickupUsers(userNames: string[]) {
await this.selectWith("Pick-up");
for (const [index, userName] of userNames.entries()) {
await selectUser({
page: this.page,
labelName: `User ${index + 2}`,
userName,
});
}
}
/** Posts as a pick-up saved from an earlier post, identified by its member names. */
async selectSavedPickup(userNames: string[]) {
await this.selectWith(`Pick-up ${userNames.join(", ")}`);
}
private async selectWith(optionName: string) {
await this.page.getByLabel("With").click();
await this.page
.getByRole("option", { name: optionName, exact: true })
.click();
}
/** The user field of the pick-up member, counting the author as the first one. */
pickupUser(nth: number) {
return this.page.getByLabel(`User ${nth}`);
}
/** Limits who sees the post to one of the author's associations. */
async selectVisibility(associationName: string) {
await this.page
.getByLabel("Visibility")
.selectOption({ label: associationName });
}
async selectTournamentMaps(tournamentName: string) {
await this.form.select("maps", "TOURNAMENT");
await selectTournament({ page: this.page, query: tournamentName });
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,142 @@
import type { Page } from "@playwright/test";
import {
cancelScrimFormSchema,
submitMapListFormSchema,
} from "~/features/scrims/scrims-schemas";
import { scrimPage } from "~/utils/urls";
import {
expect,
navigate,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
type Side = "ALPHA" | "BRAVO";
type Tab = "rosters" | "action" | "result" | "stats";
const TAB_NAMES: Record<Tab, string> = {
rosters: "Rosters",
action: "Action",
result: "Result",
stats: "Stats",
};
const WINNER_RADIO_TEST_IDS: Record<Side, string> = {
ALPHA: "winner-radio-1",
BRAVO: "winner-radio-2",
};
/** A booked scrim: its rosters, the map by map reporting and the resulting stats. */
export class ScrimPage {
private readonly page: Page;
readonly locators;
readonly mapListForm;
readonly cancelForm;
constructor(page: Page) {
this.page = page;
this.mapListForm = createFormHelpers(page, submitMapListFormSchema, {
submitTestId: "submit-map-list-button",
});
this.cancelForm = createFormHelpers(page, cancelScrimFormSchema, {
submitTestId: "cancel-scrim-submit",
});
this.locators = {
subtitle: page.getByText("Scheduled scrim"),
mapListForm: page.getByTestId("scrim-map-list-form"),
manageMapListsButton: page.getByRole("button", {
name: /Manage map lists/i,
}),
reportScoreButton: page.getByTestId("report-score-button"),
undoMapButton: page.getByTestId("undo-map-button"),
replayMapButton: page.getByTestId("replay-map-button"),
statsRoot: page.getByTestId("scrim-stats-root"),
selectedWinner: page.locator(
'[data-testid^="winner-radio-"][data-selected="true"]',
),
};
}
async goto(scrimId: number) {
await navigate({ page: this.page, url: scrimPage(scrimId) });
}
async openTab(tab: Tab) {
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
}
mapListRow(side: Side) {
return this.page.getByTestId(`map-list-row-${side}`);
}
/** Submits the map list with the source the form defaults to. */
async submitMapList() {
await waitForPOSTResponse(this.page, () => this.mapListForm.submit());
}
async submitPoolMapList(serializedPool: string) {
await this.page.getByLabel("Pool URL").click();
await this.mapListForm.fill("serializedPool", serializedPool);
await this.submitMapList();
}
/** The map lists of both sides are behind a collapsed section while a map is being played. */
async openMapListManager() {
await this.locators.manageMapListsButton.click();
}
async removeOwnMapList(side: Side) {
await this.mapListRow(side)
.getByLabel(/Remove list/i)
.click();
await waitForPOSTResponse(this.page, () =>
submit(this.page, "confirm-button"),
);
}
async reportMapWinner(winner: Side) {
// the report of the previous map leaves the radios selected until the
// loader revalidation swaps the next map in
await expect(this.locators.selectedWinner).toHaveCount(0);
await this.page.getByTestId(WINNER_RADIO_TEST_IDS[winner]).click();
await submit(this.page, "report-score-button");
}
async undoMap() {
await submit(this.page, "undo-map-button");
}
/** Replaces the map waiting to be played with a copy of the last reported one. */
async replayMap() {
await submit(this.page, "replay-map-button");
}
async cancel(reason: string) {
await this.page.getByRole("button", { name: "Cancel" }).click();
await this.cancelForm.fill("reason", reason);
await submit(this.page, "cancel-scrim-submit");
}
/** Groups the stats by mode, counting maps outside the viewer's own pool too. */
async showModeStatsOfAllMaps() {
await this.locators.statsRoot.getByText("Mode", { exact: true }).click();
await this.page.getByRole("switch").click({ force: true });
}
/** How many maps the stats table accounts for, from the viewer's point of view. */
async reportedMapCount() {
const wins = await this.locators.statsRoot
.locator("tbody tr td:nth-child(2)")
.allInnerTexts();
const losses = await this.locators.statsRoot
.locator("tbody tr td:nth-child(3)")
.allInnerTexts();
return [...wins, ...losses].reduce(
(total, cell) => total + Number(cell),
0,
);
}
}

View File

@@ -0,0 +1,168 @@
import type { Page } from "@playwright/test";
import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas";
import { scrimsPage } from "~/utils/urls";
import {
expectIsHydrated,
modalClickConfirmButton,
navigate,
selectUser,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { AssociationsPage } from "../associations/associations-page";
import { ScrimPage } from "./scrim-page";
type Tab = "available" | "owned" | "booked";
const TAB_NAMES: Record<Tab, string> = {
available: "Available",
owned: "Owned",
booked: "Booked",
};
export class ScrimsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
associationsLink: page.getByRole("link", { name: "Associations" }),
requestButtons: page.getByTestId("request-scrim-button"),
viewRequestButtons: page.getByTestId("view-request-button"),
acceptRequestButtons: page.getByTestId("confirm-modal-trigger-button"),
togglePendingRequestsButton: page.getByTestId(
"toggle-pending-requests-button",
),
deleteButtons: page.getByRole("button", { name: "Delete" }),
contactLinks: page.getByRole("link", { name: "Contact" }),
limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"),
tournamentPopover: page.getByTestId("tournament-popover-trigger"),
canceledLabel: page.getByText("Canceled"),
divsFilterPill: page.getByTestId("divs-filter"),
addFilterButton: page.getByTestId("add-filter-button"),
saveFiltersAsDefaultButton: page.getByTestId(
"save-filters-as-default-button",
),
};
}
async goto() {
await navigate({ page: this.page, url: scrimsPage() });
}
async reload() {
await this.page.reload();
await expectIsHydrated(this.page);
}
post(text: string) {
return this.page.getByText(text);
}
/** Sets both selects of the "Divs" filter pill's popover. */
async filterByDivs({ max, min }: { max: string; min: string }) {
await this.page.keyboard.press("Escape");
await this.openDivsFilter();
await this.page.getByLabel("Max div").selectOption(max);
await this.page.getByLabel("Min div").selectOption(min);
await this.page.keyboard.press("Escape");
}
/** Resets the "Divs" filter, hiding the pill. */
async removeDivsFilter() {
await this.page.keyboard.press("Escape");
await this.page.getByTestId("divs-filter-remove").click();
}
/** Persists the current filters as the user's default. */
async saveFiltersAsDefault() {
await waitForPOSTResponse(this.page, () =>
this.locators.saveFiltersAsDefaultButton.click(),
);
}
/** The pill is only rendered while its filter differs from the default. */
private async openDivsFilter() {
if (await this.locators.divsFilterPill.isVisible()) {
await this.locators.divsFilterPill.click();
return;
}
await this.locators.addFilterButton.click();
await this.page.getByTestId("menu-item-divs-filter").click();
}
async openTab(tab: Tab) {
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
}
async openAssociations() {
await this.locators.associationsLink.click();
return new AssociationsPage(this.page);
}
async requestFirst() {
await this.locators.requestButtons.first().click();
return new ScrimRequestModal(this.page);
}
/** Posts of the available tab whose request is pending are hidden until asked for. */
async showPendingRequests() {
await this.locators.togglePendingRequestsButton.first().click();
}
async cancelPendingRequest() {
await this.locators.viewRequestButtons.first().click();
await this.page.getByRole("button", { name: "Cancel" }).click();
}
async acceptFirstRequest() {
await this.locators.acceptRequestButtons.first().click();
await modalClickConfirmButton(this.page);
}
async deleteFirstPost() {
await this.locators.deleteButtons.first().click();
await modalClickConfirmButton(this.page);
}
async openFirstBookedScrim() {
await this.locators.contactLinks.first().click();
return new ScrimPage(this.page);
}
}
/** The dialog for requesting a scrim of somebody else's post. */
class ScrimRequestModal {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, scrimRequestFormSchema);
}
/** The requester themselves is the first of the pick-up, the rest are chosen here. */
async selectPickupUsers(userNames: string[]) {
for (const [index, userName] of userNames.entries()) {
await selectUser({
page: this.page,
labelName: `User ${index + 2}`,
userName,
});
}
}
/** Only posts with a flexible start time offer times to pick from. */
async selectStartTime(nth: number) {
await this.page
.getByLabel(this.form.getLabel("at"))
.selectOption({ index: nth });
}
async send() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,47 @@
import type { Page } from "@playwright/test";
const TYPE_LABELS = {
weapons: "Weapons",
users: "Users",
teams: "Teams",
organizations: "Organizations",
tournaments: "Tournaments",
};
export class GlobalSearchDialog {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
openButton: page.getByRole("button", { name: /Search/ }),
dialog: page.getByRole("dialog", { name: "Search" }),
input: page.getByPlaceholder("Search..."),
};
}
option(name: string) {
return this.page.getByRole("option", { name, exact: true });
}
async open() {
await this.locators.openButton.click();
await this.locators.dialog.waitFor({ state: "visible" });
}
/** Which kind of entity the query searches; remembered across dialog openings. */
async selectType(type: keyof typeof TYPE_LABELS) {
await this.locators.dialog.getByText(TYPE_LABELS[type]).click();
}
async search(query: string) {
await this.locators.input.fill(query);
}
async selectOption(name: string) {
const option = this.option(name);
await option.waitFor({ state: "visible" });
await option.click({ force: true });
}
}

View File

@@ -0,0 +1,53 @@
import type { Locator } from "@playwright/test";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { modalClickConfirmButton, submit } from "../../helpers/playwright";
import { UserCard } from "../user/user-card";
export class GroupCard {
readonly root: Locator;
readonly members: Locator;
readonly actionButton: Locator;
readonly suggestButton: Locator;
/** Note of who in the own group invited or suggested this group. */
readonly trail: Locator;
/** One per member who missed a ready check, and can thus be kicked. */
readonly kickButtons: Locator;
/** One per mode the match against this group would be played on. */
readonly modes: Locator;
constructor(root: Locator) {
this.root = root;
this.members = root.getByTestId("sendouq-group-card-member");
this.actionButton = root.getByTestId("group-card-action-button");
this.suggestButton = root.getByTestId("group-card-suggest-button");
this.trail = root.getByTestId("group-card-trail");
this.kickButtons = root.getByTestId("group-card-kick-button");
this.modes = root.getByTestId(/^group-card-mode-/);
}
mode(mode: ModeShort) {
return this.root.getByTestId(`group-card-mode-${mode}`);
}
/** Challenges or invites the group, accepts what it offered, or undoes either. */
pressAction() {
return submit(this.root.page(), this.actionButton);
}
pressSuggest() {
return submit(this.root.page(), this.suggestButton);
}
/** Kicks the first member the card offers a kick button for, confirming the dialog. */
async pressKick() {
await this.kickButtons.first().click();
await modalClickConfirmButton(this.root.page());
}
openMemberCard(name: string) {
return UserCard.open(
this.root.page(),
this.root.getByRole("button", { name }),
);
}
}

View File

@@ -0,0 +1,48 @@
import type { Page } from "@playwright/test";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_LOOKING_PREVIEW_PAGE,
} from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { GroupCard } from "./group-card";
export class SendouQLookingPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
groupCards: page.getByTestId("sendouq-group-card"),
undoButtons: page.getByRole("button", { name: "Undo" }),
actionButtons: page.getByTestId("group-card-action-button"),
suggestButtons: page.getByTestId("group-card-suggest-button"),
};
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_LOOKING_PAGE });
}
/** Opens the supporter only view of the queue, without being in it. */
async gotoPreview() {
await navigate({ page: this.page, url: SENDOUQ_LOOKING_PREVIEW_PAGE });
}
/** The own group's card, always the first one on the page. */
get ownGroupCard() {
return this.groupCard(0);
}
groupCard(nth: number) {
return new GroupCard(this.locators.groupCards.nth(nth));
}
/**
* Presses the action another group's card offers: challenging or inviting it,
* accepting what it offered, or undoing either.
*/
async pressGroupAction() {
await submit(this.page, "group-card-action-button");
}
}

View File

@@ -0,0 +1,234 @@
import type { Page } from "@playwright/test";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
import { sendouQMatchPage } from "~/utils/urls";
import {
expect,
navigate,
selectWeapon,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { UserCard } from "../user/user-card";
type Side = "ALPHA" | "BRAVO";
type Tab = "action" | "result" | "rosters";
const MAPS_TO_WIN = Math.ceil(SENDOUQ_BEST_OF / 2);
const TEAM_NAMES: Record<Side, string> = {
ALPHA: "Group Alpha",
BRAVO: "Group Bravo",
};
export class SendouQMatchPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
reportScoreButton: page.getByTestId("report-score-button"),
confirmSetEndButton: page.getByTestId("confirm-set-end-button"),
// the weapon reporter's own submit, which has no test id of its own
submitWeaponButton: page
.getByRole("button", { name: "Submit", exact: true })
.last(),
undoReportButton: page.getByRole("button", { name: "Undo report" }),
reportWeaponsButton: page.getByRole("button", {
name: "Report used weapons",
}),
undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }),
confirmScoreButton: page.getByRole("button", { name: "Confirm score" }),
requestCancelButton: page.getByRole("button", { name: "Request cancel" }),
cancelPendingText: page.getByText("Pending other team's confirmation"),
cancelPrompt: page.getByText("Accept canceling the set?"),
canceledText: page.getByText("Match canceled"),
lookAgainButton: page.getByRole("button", {
name: "Look again with same group",
}),
rejoinQueueButton: page.getByRole("button", { name: "Rejoin queue" }),
declinedText: page.getByText("You declined to continue"),
votedYes: page.getByLabel("voted yes"),
votedNo: page.getByLabel("voted no"),
pendingVotes: page.getByLabel("pending"),
selectedWinner: page.locator(
'[data-testid^="winner-radio-"][data-selected="true"]',
),
};
}
async goto(matchId: number, tab: Tab = "action") {
await navigate({
page: this.page,
url: `${sendouQMatchPage(matchId)}?tab=${tab}`,
});
}
score(alpha: number, bravo: number) {
return this.page.getByText(new RegExp(`${alpha}\\s*-\\s*${bravo}`)).first();
}
/** The banner of the map being played, when it is one of `maps`. */
currentMap(maps: ModeWithStage[]) {
const alternatives = maps.map((map) => `${map.mode}-${map.stageId}`);
return this.page.getByTestId(
new RegExp(`^banner-map-(${alternatives.join("|")})$`),
);
}
/** The badge of a mode played in the set, one per map unless every map shares the mode. */
modeProgress(mode: ModeWithStage["mode"]) {
return this.page.getByTestId(`mode-progress-${mode}`);
}
/** How many maps the one shown mode is played on, shown only when every map shares it. */
mapCountText(count: number) {
return this.page.getByText(`\u00d7${count}`);
}
reportedWeaponImage(name: string) {
return this.page.getByRole("img", { name }).first();
}
openUserCard(name: string | RegExp) {
return UserCard.open(
this.page,
this.page.getByRole("button", { name }).first(),
);
}
/** Reports the winner of every map of the set, `winner` winning all of them. */
async reportSweep(winner: Side) {
for (let i = 0; i < MAPS_TO_WIN - 1; i++) {
await this.reportMapWinner(winner);
}
await this.reportSetEndingMap(winner);
}
async reportMapWinner(winner: Side) {
await this.selectMapWinner(winner);
await waitForPOSTResponse(this.page, async () => {
await this.locators.reportScoreButton.click();
});
// Wait for the action panel to remount with the new reportedCount.
// waitForPOSTResponse only waits for the POST itself, not the loader
// revalidation. MatchActionTab is keyed on reportedCount, so it (and the
// nested WeaponReporter) unmounts and remounts when the loader returns.
// Without this wait, a follow-up click can land on the about-to-unmount
// instance — local state set by that click (e.g. WeaponReporter's isOpen)
// is then thrown away on remount.
await expect(this.locators.selectedWinner).toHaveCount(0);
}
/** Reports the map that wins the set for `winner`, through its confirmation screen. */
async reportSetEndingMap(winner: Side) {
await this.selectMapWinner(winner);
await this.locators.reportScoreButton.click();
await waitForPOSTResponse(this.page, async () => {
await this.locators.confirmSetEndButton.click();
});
}
async undoReport() {
await waitForPOSTResponse(this.page, async () => {
await this.locators.undoReportButton.click();
});
}
async reportWeapon(name: string) {
await this.locators.reportWeaponsButton.click();
await selectWeapon({ page: this.page, name });
await waitForPOSTResponse(this.page, async () => {
await this.locators.submitWeaponButton.click();
});
}
async confirmScore() {
await waitForPOSTResponse(this.page, async () => {
await this.locators.confirmScoreButton.click();
});
}
async requestCancel({ reason }: { reason: string }) {
await this.locators.requestCancelButton.click();
await this.submitCancelDialog(reason);
}
async refuseCancel() {
await waitForPOSTResponse(this.page, async () => {
await this.page.getByRole("button", { name: "Refuse" }).click();
});
}
async acceptCancel({ reason }: { reason: string }) {
await this.page.getByRole("button", { name: "Accept" }).click();
await this.submitCancelDialog(reason);
}
/** Nominates the first listed player, fills the reason and submits the cancel dialog. */
private async submitCancelDialog(reason: string) {
const dialog = this.page.getByRole("dialog");
await dialog.getByRole("checkbox").first().check();
await dialog.getByLabel("Reason").fill(reason);
await waitForPOSTResponse(this.page, async () => {
await dialog.getByTestId("cancel-match-submit").click();
});
}
async voteYes() {
await waitForPOSTResponse(this.page, async () => {
await this.page.getByRole("button", { name: "Yes, continue" }).click();
});
}
async voteNo() {
await this.page.getByRole("button", { name: "No, I'm done" }).click();
await this.confirmDialog();
}
async lookAgain() {
await waitForPOSTResponse(this.page, async () => {
await this.locators.lookAgainButton.click();
});
}
async rejoinQueue() {
await waitForPOSTResponse(this.page, async () => {
await this.locators.rejoinQueueButton.click();
});
}
private async selectMapWinner(winner: Side) {
const teamName = TEAM_NAMES[winner];
// Wait for the action panel to settle before clicking. waitForPOSTResponse
// only waits for the POST itself; the loader revalidation that swaps in the
// next map's component runs after, so a previous winner can still be
// `data-selected="true"` here. Clicking too early hits the about-to-unmount
// label and the selection is lost on remount.
await expect(this.locators.selectedWinner).toHaveCount(0);
// react-aria's Radio renders a hidden input behind a span overlay; click the
// wrapping label so the press handler fires and updates winnerId. The press
// occasionally registers a press-start without a press-end (same React Aria
// nondeterminism as in waitForPOSTResponse), so the selection silently drops
// and Submit stays disabled. Re-issue the click until the radio reports
// selected; otherwise the Submit-click retry loop spins on a disabled button.
const label = this.page.locator(
`label:has(input[aria-label="${teamName}"])`,
);
const radio = this.page.locator(
`[data-testid^="winner-radio-"]:has(input[aria-label="${teamName}"])`,
);
await expect(async () => {
await label.click();
await expect(radio).toHaveAttribute("data-selected", "true", {
timeout: 1_000,
});
}).toPass();
}
private async confirmDialog() {
await waitForPOSTResponse(this.page, async () => {
await this.page.getByTestId("confirm-button").click();
});
}
}

View File

@@ -0,0 +1,46 @@
import type { Page } from "@playwright/test";
import { sendouQInviteLink } from "~/features/sendouq/q-urls";
import { SENDOUQ_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { SendouQLookingPage } from "./sendouq-looking-page";
import { SendouQPreparingPage } from "./sendouq-preparing-page";
export class SendouQPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
joinWithMatesButton: page.getByRole("button", {
name: "Join with mates",
}),
joinGroupDialog: page.getByRole("dialog"),
joinGroupButton: page.getByRole("button", { name: "Join", exact: true }),
};
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_PAGE });
}
async gotoInviteLink(inviteCode: string) {
await navigate({ page: this.page, url: sendouQInviteLink(inviteCode) });
}
async joinWithMates() {
await this.locators.joinWithMatesButton.click();
return new SendouQPreparingPage(this.page);
}
async joinSolo() {
await submit(this.page, "join-solo-button");
return new SendouQLookingPage(this.page);
}
/** Accepts the invite of the group the join link was made by. */
async joinInvitedGroup() {
await this.locators.joinGroupButton.click();
return new SendouQPreparingPage(this.page);
}
}

View File

@@ -0,0 +1,54 @@
import type { Page } from "@playwright/test";
import { qSearchParams } from "~/features/sendouq/q-search-params";
import invariant from "~/utils/invariant";
import { SENDOUQ_PREPARING_PAGE } from "~/utils/urls";
import { expect, navigate } from "../../helpers/playwright";
import { GroupCard } from "./group-card";
import { SendouQLookingPage } from "./sendouq-looking-page";
export class SendouQPreparingPage {
private readonly page: Page;
readonly locators;
readonly groupCard: GroupCard;
constructor(page: Page) {
this.page = page;
this.locators = {
inviteLinkInput: page.getByLabel("Invite link"),
friendSelect: page.getByLabel("Quick add"),
// the button is icon only, so there is no accessible name to find it by
addFriendButton: page.locator(
'button[type="submit"][value="ADD_FRIEND"]',
),
joinQueueButton: page.getByRole("button", { name: "Join the queue" }),
};
this.groupCard = new GroupCard(
page.getByTestId("sendouq-group-card").first(),
);
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_PREPARING_PAGE });
}
/** The code of the invite link, for joining the group without being a friend. */
async inviteCode() {
const inviteLink = await this.locators.inviteLinkInput.inputValue();
const { join: code } = qSearchParams.parse(new URL(inviteLink));
invariant(code, `No invite code in the invite link: ${inviteLink}`);
return code;
}
/** Adds the first friend the quick add dropdown offers to the group. */
async addFirstFriend() {
await this.locators.friendSelect.selectOption({ index: 1 });
await expect(this.locators.addFriendButton).toBeEnabled();
await this.locators.addFriendButton.click();
}
async joinQueue() {
await this.locators.joinQueueButton.click();
return new SendouQLookingPage(this.page);
}
}

View File

@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
import { SENDOUQ_READY_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { GroupCard } from "./group-card";
export class SendouQReadyPage {
private readonly page: Page;
readonly locators;
/** The own group's card, the only one showing who its members are. */
readonly groupCard: GroupCard;
constructor(page: Page) {
this.page = page;
this.locators = {
countdown: page.getByTestId("ready-check-countdown"),
readyButton: page.getByRole("button", { name: "Ready to play" }),
confirmedText: page.getByTestId("ready-confirmed"),
hiddenGroupCard: page.getByTestId("sendouq-hidden-group-card"),
membersReady: page.getByTestId("member-ready"),
membersNotReady: page.getByTestId("member-not-ready"),
};
this.groupCard = new GroupCard(
page.getByTestId("sendouq-group-card").first(),
);
}
async goto() {
await navigate({ page: this.page, url: SENDOUQ_READY_PAGE });
}
confirmReady() {
return submit(this.page, this.locators.readyButton);
}
}

View File

@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import type { ModeShort } from "~/modules/in-game-lists/types";
export const SELECTED_MAP_CLASS = /mapButtonGreyedOut/;
type PreferenceLabel = "Avoid" | "Neutral" | "Prefer";
/** The map/mode preferences field shared by the settings match profile tab and the team edit page. */
export class MapModePreferencesField {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async setModePreference(mode: ModeShort, preference: PreferenceLabel) {
// the selected-state icon overlays the radio and intercepts clicks
await this.preferenceRadio(mode, preference).click({ force: true });
}
preferenceRadio(mode: ModeShort, preference: PreferenceLabel) {
const name =
preference === "Neutral"
? "Neutral towards the mode"
: `${preference} the mode`;
return this.page
.getByRole("radiogroup", { name: `Select preference towards ${mode}` })
.getByRole("radio", { name });
}
async selectModeTab(mode: ModeShort) {
await this.page.getByTestId(`map-pool-mode-tab-${mode}`).click();
}
mapButton(mode: ModeShort, stageId: number) {
return this.page.getByTestId(`map-pool-${mode}-${stageId}`);
}
}

View File

@@ -0,0 +1,32 @@
import type { Page } from "@playwright/test";
import { updateMatchProfileSchema } from "~/features/settings/match-profile-schemas";
import { MATCH_PROFILE_PAGE } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { MapModePreferencesField } from "./map-mode-preferences-field";
export class MatchProfilePage {
private readonly page: Page;
readonly form;
readonly maps;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, updateMatchProfileSchema);
this.maps = new MapModePreferencesField(page);
this.locators = {
avoidedModePoolError: page.getByText(
"Can't have map pool for a mode that was avoided",
),
};
}
async goto() {
await navigate({ page: this.page, url: MATCH_PROFILE_PAGE });
}
async save() {
await submit(this.page);
}
}

View File

@@ -0,0 +1,91 @@
import type { Page } from "@playwright/test";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
spoilerFreeModeSchema,
} from "~/features/settings/settings-schemas";
import { SETTINGS_PAGE } from "~/utils/urls";
import {
expectIsHydrated,
navigate,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
type SettingsTab = "preferences" | "locale" | "theme";
/** `/settings` preference toggles and the theme tab; the match profile tab has its own page object. */
export class SettingsPage {
private readonly page: Page;
readonly locators;
private readonly buildAbilitySortingForm;
private readonly clockFormatForm;
private readonly spoilerFreeModeForm;
constructor(page: Page) {
this.page = page;
this.buildAbilitySortingForm = createFormHelpers(
page,
disableBuildAbilitySortingSchema,
);
this.clockFormatForm = createFormHelpers(page, clockFormatSchema);
this.spoilerFreeModeForm = createFormHelpers(page, spoilerFreeModeSchema);
this.locators = {
baseHueSlider: page.locator("#base-hue"),
saveThemeButton: page.getByRole("button", { name: "Save" }).first(),
resetThemeButton: page.getByRole("button", { name: "Reset" }).first(),
};
}
async goto(tab: SettingsTab) {
await navigate({ page: this.page, url: `${SETTINGS_PAGE}?tab=${tab}` });
}
async disableBuildAbilitySorting() {
await this.goto("preferences");
await waitForPOSTResponse(this.page, () =>
this.buildAbilitySortingForm.check("newValue"),
);
}
async setClockFormat(format: "12h" | "24h") {
await this.goto("locale");
await waitForPOSTResponse(this.page, () =>
this.clockFormatForm.select("newValue", format),
);
}
async enableSpoilerFreeMode() {
await this.goto("preferences");
await waitForPOSTResponse(this.page, () =>
this.spoilerFreeModeForm.check("newValue"),
);
}
async setBaseHue(value: string) {
await this.locators.baseHueSlider.fill(value);
}
async saveTheme() {
await waitForPOSTResponse(this.page, () =>
this.locators.saveThemeButton.click(),
);
}
async resetTheme() {
await waitForPOSTResponse(this.page, () =>
this.locators.resetThemeButton.click(),
);
}
hasCustomTheme() {
return this.page
.locator("html")
.evaluate((el) => el.style.getPropertyValue("--_base-h") !== "");
}
async reload() {
await this.page.reload();
await expectIsHydrated(this.page);
}
}

View File

@@ -0,0 +1,21 @@
import type { Page } from "@playwright/test";
import { navigate, submit } from "../../helpers/playwright";
import { TeamPage } from "./team-page";
export class JoinTeamPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/** Takes the whole invite link as shown on the roster page. */
async goto(inviteLink: string) {
await navigate({ page: this.page, url: inviteLink });
}
async join() {
await submit(this.page);
return new TeamPage(this.page);
}
}

View File

@@ -0,0 +1,19 @@
import type { Page } from "@playwright/test";
import { createTeamSchema } from "~/features/team/team-schemas";
import { NEW_TEAM_PAGE } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
export class NewTeamPage {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, createTeamSchema);
}
async goto() {
await navigate({ page: this.page, url: NEW_TEAM_PAGE });
}
}

View File

@@ -0,0 +1,43 @@
import type { Page } from "@playwright/test";
import { editTeamFormSchema } from "~/features/team/team-schemas";
import { editTeamPage } from "~/utils/urls";
import {
modalClickConfirmButton,
navigate,
submit,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { MapModePreferencesField } from "../settings/map-mode-preferences-field";
export class TeamEditPage {
private readonly page: Page;
readonly form;
readonly maps;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, editTeamFormSchema, {
submitTestId: "edit-team-submit-button",
});
this.maps = new MapModePreferencesField(page);
this.locators = {
removeMapPreferencesButton: page.getByRole("button", {
name: "Remove team map preferences",
}),
};
}
async goto(customUrl: string) {
await navigate({ page: this.page, url: editTeamPage(customUrl) });
}
async saveMapPreferences() {
await submit(this.page, "team-map-preferences-submit-button");
}
async removeMapPreferences() {
await this.locators.removeMapPreferencesButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,92 @@
import type { Page } from "@playwright/test";
import { teamPage } from "~/utils/urls";
import {
modalClickConfirmButton,
navigate,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { TeamEditPage } from "./team-edit-page";
import { TeamRosterPage } from "./team-roster-page";
export class TeamPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
bio: page.getByTestId("team-bio"),
bskyLink: page.getByTestId("bsky-link").first(),
manageRosterButton: page.getByTestId("manage-roster-button"),
editTeamButton: page.getByTestId("edit-team-button"),
actionsMenuButton: page.getByTestId("team-actions-menu-button"),
mainTeamIndicator: page.getByTestId("main-team-indicator"),
makeMainTeamButton: page.getByTestId("make-main-team-button"),
leaveTeamButton: page.getByTestId("leave-team-button"),
deleteTeamButton: page.getByTestId("delete-team-button"),
otherRolesTab: page.getByRole("tab", { name: /Other/ }),
confirmDialog: page.getByRole("dialog"),
};
}
async goto(customUrl: string) {
await navigate({ page: this.page, url: teamPage(customUrl) });
}
memberRole(nth: number) {
return this.page.getByTestId(`member-row-role-${nth}`);
}
ownerBadge(userId: number) {
return this.page.getByTestId(`member-owner-${userId}`);
}
customRole(name: string) {
return this.page.getByText(name).first();
}
async openManageRoster() {
await this.locators.manageRosterButton.click();
return new TeamRosterPage(this.page);
}
async openEdit() {
await this.locators.editTeamButton.click();
return new TeamEditPage(this.page);
}
async openActionsMenu() {
await this.locators.actionsMenuButton.click();
}
/** Requires the actions menu to be open. */
async makeMainTeam() {
await waitForPOSTResponse(this.page, () =>
this.locators.makeMainTeamButton.click(),
);
}
/**
* Opens the leave confirmation dialog, which names the new owner if the
* leaver owns the team. Requires the actions menu to be open.
*/
async startLeaving() {
await this.locators.leaveTeamButton.click();
}
async confirmLeaving() {
await modalClickConfirmButton(this.page);
}
/** Requires the actions menu to be open. */
async leave() {
await this.startLeaving();
await this.confirmLeaving();
}
/** Requires the actions menu to be open. */
async delete() {
await this.locators.deleteTeamButton.click();
await modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,88 @@
import type { Page } from "@playwright/test";
import type {
MemberRole,
MemberRoleType,
} from "~/features/team/team-constants";
import type { CUSTOM_ROLE_VALUE } from "~/features/team/team-schemas";
import { manageTeamRosterPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
export class TeamRosterPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
inviteLink: page.getByTestId("invite-link"),
editorToggles: page.getByLabel("Editor"),
};
}
async goto(customUrl: string) {
await navigate({ page: this.page, url: manageTeamRosterPage(customUrl) });
}
memberRow(nth: number) {
return new MemberRow(this.page, nth);
}
inviteLink() {
return this.locators.inviteLink.innerText();
}
async resetInviteLink() {
await submit(this.page, "reset-invite-link-button");
}
/** The owner and the user themselves can't be made editors, so they have no toggle. */
async makeEditor(nth: number) {
await this.locators.editorToggles.nth(nth).click({ force: true });
}
async save() {
await submit(this.page);
}
}
class MemberRow {
private readonly row;
/** The array field's wrapper, holding the reorder & remove buttons. */
private readonly fieldset;
readonly locators;
constructor(page: Page, index: number) {
this.row = page.getByTestId(`member-row-${index}`);
this.fieldset = page.locator(
`fieldset:has([data-testid='member-row-${index}'])`,
);
this.locators = {
row: this.row,
username: page.getByTestId(`member-row-username-${index}`),
roleSelect: this.row.locator("select").first(),
customRoleInput: this.row.getByRole("textbox"),
roleTypeSelect: this.row.locator("select").nth(1),
removeButton: this.fieldset.getByRole("button", { name: "Remove item" }),
moveUpButton: this.fieldset.getByRole("button", { name: "Move up" }),
moveDownButton: this.fieldset.getByRole("button", { name: "Move down" }),
};
}
async selectRole(role: MemberRole | typeof CUSTOM_ROLE_VALUE) {
await this.locators.roleSelect.selectOption(role);
}
async setCustomRole(name: string, roleType: MemberRoleType) {
await this.selectRole("CUSTOM");
await this.locators.customRoleInput.fill(name);
await this.locators.roleTypeSelect.selectOption(roleType);
}
async remove() {
await this.locators.removeButton.click();
}
async moveDown() {
await this.locators.moveDownButton.click();
}
}

View File

@@ -0,0 +1,106 @@
import type { Page } from "@playwright/test";
import invariant from "~/utils/invariant";
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
import { expect, expectIsHydrated, navigate } from "../../helpers/playwright";
type ItemType =
| "main-weapon"
| "sub-weapon"
| "special-weapon"
| "stage"
| "mode"
| "stage-mode"
| "ability";
type Toggle =
| "noDuplicates"
| "showTierHeaders"
| "hideAltKits"
| "hideAltSkins";
const TAB_NAMES: Record<ItemType, string> = {
"main-weapon": "Main Weapons",
"sub-weapon": "Sub Weapons",
"special-weapon": "Special Weapons",
stage: "Stages",
mode: "Modes",
"stage-mode": "Stage + Modes",
ability: "Abilities",
};
const TOGGLE_NAMES: Record<Toggle, string> = {
noDuplicates: "No duplicates",
showTierHeaders: "Show tier headers",
hideAltKits: "Hide alt kits",
hideAltSkins: "Hide alt skins",
};
export class TierListMakerPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
emptyTiersDragMode: page.getByText("Drop items here"),
emptyTiersClickMode: page.getByText("Click items to add here"),
};
}
async goto() {
await navigate({ page: this.page, url: TIER_LIST_MAKER_URL });
}
/** The tier list state lives in the url, so a reload restores it. */
async reload() {
await this.page.reload();
await expectIsHydrated(this.page);
}
poolItems(type: ItemType) {
return this.page
.getByRole("tabpanel")
.locator(`[data-item-id^="${type}:"]`);
}
async openTab(type: ItemType) {
await this.page.getByRole("tab", { name: TAB_NAMES[type] }).click();
}
async setPlacementMode(mode: "drag" | "click") {
await this.page
.getByText(mode === "drag" ? "Drag & drop" : "Click to place")
.click();
}
async toggle(name: Toggle) {
await this.page.getByText(TOGGLE_NAMES[name]).click();
}
async dragFirstItemToLastEmptyTier(type: ItemType) {
const emptyTiers = this.locators.emptyTiersDragMode;
const emptyCountBefore = await emptyTiers.count();
await this.poolItems(type).first().hover();
await this.page.mouse.down();
const tierBox = await emptyTiers.last().boundingBox();
invariant(tierBox, "The tier dropped on has no bounding box");
await this.page.mouse.move(
tierBox.x + tierBox.width / 2,
tierBox.y + tierBox.height / 2,
{ steps: 10 },
);
await this.page.mouse.up();
await expect(emptyTiers).toHaveCount(emptyCountBefore - 1);
}
async clickFirstItem(type: ItemType) {
await this.poolItems(type).first().click();
}
async selectFirstEmptyTier() {
await this.locators.emptyTiersClickMode.first().click();
}
}

View File

@@ -0,0 +1,14 @@
import type { Page } from "@playwright/test";
/** The placement rows shared by `/xsearch` and `/xsearch/player/:id`. */
export class PlacementsTable {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
row(nth: number) {
return this.page.getByTestId(`placement-row-${nth}`);
}
}

View File

@@ -0,0 +1,55 @@
import type { Page } from "@playwright/test";
import type { Tables } from "~/db/tables";
import { topSearchPage } from "~/features/top-search/top-search-urls";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import { navigate } from "../../helpers/playwright";
import { PlacementsTable } from "./placements-table";
type Leaderboard = Pick<
Tables["XRankPlacement"],
"month" | "year" | "region"
> & {
mode: RankedModeShort;
};
const MODE_NAMES: Record<RankedModeShort, string> = {
SZ: "Splat Zones",
TC: "Tower Control",
RM: "Rainmaker",
CB: "Clam Blitz",
};
const DIVISION_NAMES: Record<Tables["XRankPlacement"]["region"], string> = {
WEST: "Tentatek",
JPN: "Takoroka",
};
/** `/xsearch` */
export class TopSearchPage {
private readonly page: Page;
readonly placements;
readonly locators;
constructor(page: Page) {
this.page = page;
this.placements = new PlacementsTable(page);
this.locators = {
seasonMonthsSelect: this.page.getByTestId("xsearch-select"),
};
}
async goto() {
await navigate({ page: this.page, url: topSearchPage() });
}
async selectLeaderboard({ month, year, mode, region }: Leaderboard) {
await this.locators.seasonMonthsSelect.click();
// the trigger renders a copy of the selected option, so scope to the listbox
await this.page
.getByRole("listbox")
.getByTestId(`xsearch-select-option-${month}-${year}`)
.click();
await this.page.getByText(MODE_NAMES[mode], { exact: true }).click();
await this.page.getByText(DIVISION_NAMES[region], { exact: true }).click();
}
}

View File

@@ -0,0 +1,31 @@
import type { Page } from "@playwright/test";
import { topSearchPlayerPage } from "~/features/top-search/top-search-urls";
import { navigate } from "../../helpers/playwright";
import { PlacementsTable } from "./placements-table";
import { TopSearchPage } from "./top-search-page";
/** `/xsearch/player/:id` */
export class TopSearchPlayerPage {
private readonly page: Page;
readonly placements;
readonly locators;
constructor(page: Page) {
this.page = page;
this.placements = new PlacementsTable(page);
this.locators = {
// "<player name> placements", the one heading the layout doesn't also carry
heading: this.page.getByRole("heading", { name: /placements$/ }),
};
}
async goto(playerId: number) {
await navigate({ page: this.page, url: topSearchPlayerPage(playerId) });
}
/** A row of this page leads to the whole leaderboard the placement was part of. */
async openLeaderboard(nth: number) {
await this.placements.row(nth).click();
return new TopSearchPage(this.page);
}
}

View File

@@ -0,0 +1,24 @@
import type { Page } from "@playwright/test";
import { tournamentAdminPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/to/:id/admin/audit` */
export class TournamentAdminAuditPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: `${tournamentAdminPage(tournamentId)}/audit`,
});
}
/** A table cell, not the event-filter `<option>`s sharing the same text. */
eventCell(name: string) {
return this.page.getByRole("cell", { name });
}
}

View File

@@ -0,0 +1,163 @@
import type { Page } from "@playwright/test";
import { addSubForUserFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas";
import { tournamentAdminPage } from "~/utils/urls";
import {
modalClickConfirmButton,
navigate,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { CalendarNewEventPage } from "../calendar/calendar-new-event-page";
import { TournamentAdminStaffPage } from "./tournament-admin-staff-page";
import { TournamentAdminStreamPage } from "./tournament-admin-stream-page";
import { TournamentNav } from "./tournament-nav";
/** `/to/:id/admin` — the teams list and the admin sub-page tabs. */
export class TournamentAdminPage {
private readonly page: Page;
readonly nav;
readonly addSubForm;
readonly locators;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
this.addSubForm = createFormHelpers(page, addSubForUserFormSchema);
this.locators = {
editEventInfoButton: page.getByTestId("edit-event-info-button"),
searchInput: page.getByLabel("Search teams"),
teamRows: page.getByTestId("team-row"),
teamNames: page.getByTestId("team-name"),
noSearchResultsText: page.getByText("No registrations match your search"),
exportButton: page.getByRole("button", { name: "Export" }),
addSubButton: page.getByRole("button", { name: "Add sub" }),
addSubDialogHeading: page.getByRole("heading", {
name: "Add sub post on behalf of a user",
}),
exportDialogHeading: page.getByRole("heading", {
name: "Export participants",
}),
downloadButton: page.getByRole("button", { name: "Download" }),
unregisterDialogHeading: page.getByRole("heading", {
name: /Unregister .* and delete its registration info\?/,
}),
bracketNameInputs: page.getByLabel(/^Bracket name *\*?$/),
removeBracketButtons: page.getByTestId("brackets-remove-item-button"),
};
}
async goto(tournamentId: number) {
await navigate({ page: this.page, url: tournamentAdminPage(tournamentId) });
}
async editEventInfo() {
await this.locators.editEventInfoButton.click();
return new CalendarNewEventPage(this.page);
}
adminTab(name: string) {
return this.page.getByRole("tab", { name });
}
async openStaff() {
await this.adminTab("Staff").click();
return new TournamentAdminStaffPage(this.page);
}
async openStream() {
await this.adminTab("Stream").click();
return new TournamentAdminStreamPage(this.page);
}
teamName(name: string) {
return this.locators.teamNames.filter({ hasText: name });
}
async searchTeams(query: string) {
await this.locators.searchInput.fill(query);
}
private teamRowActions(nth: number) {
return this.locators.teamRows.nth(nth).getByLabel("Actions");
}
async checkTeamIn(nth: number) {
await this.teamRowActions(nth).click();
await waitForPOSTResponse(this.page, async () => {
await this.page.getByRole("menuitem", { name: /^Check in/ }).click();
});
}
async dropOutTeam(nth: number) {
await this.teamRowActions(nth).click();
await this.page.getByRole("menuitem", { name: "Drop out" }).click();
await modalClickConfirmButton(this.page);
}
/** Checks a team in to a bracket that requires its own check-in, matched by its
* stable team id rather than the row index which shifts after the start. */
async checkTeamInToBracket(tournamentTeamId: number, bracketName: string) {
await this.page
.locator(`[data-testid="team-row"][data-team-id="${tournamentTeamId}"]`)
.getByLabel("Actions")
.click();
await waitForPOSTResponse(this.page, async () => {
await this.page
.getByRole("menuitem", { name: `Check in (${bracketName})` })
.click();
});
}
/** Opens the Brackets tab, where started brackets are locked and the rest of the progression is editable. */
async openBrackets() {
await this.adminTab("Brackets").click();
}
async renameBracket(nth: number, name: string) {
await this.locators.bracketNameInputs.nth(nth).fill(name);
}
async saveProgression() {
await submit(this.page);
}
/** Resets a bracket from the admin Brackets tab, typing out its name to confirm. */
async resetBracket(bracketName: string) {
await this.adminTab("Brackets").click();
await this.page
.getByLabel(`Type bracket name ("${bracketName}") to confirm`)
.fill(bracketName);
await submit(this.page, "reset-bracket-button");
}
async checkTeamOut(nth: number) {
await this.teamRowActions(nth).click();
await waitForPOSTResponse(this.page, async () => {
await this.page.getByRole("menuitem", { name: /^Check out/ }).click();
});
}
async unregisterTeam(nth: number) {
await this.teamRowActions(nth).click();
await this.page.getByRole("menuitem", { name: "Unregister" }).click();
}
confirmUnregister() {
return modalClickConfirmButton(this.page);
}
async openExportDialog() {
await this.locators.exportButton.click();
}
async openAddSubDialog() {
await this.locators.addSubButton.click();
}
async downloadExport() {
const downloadPromise = this.page.waitForEvent("download");
await this.locators.downloadButton.click();
return downloadPromise;
}
}

View File

@@ -0,0 +1,118 @@
import { expect, type Page } from "@playwright/test";
import { adminRegistrationFormSchema } from "~/features/tournament-admin/tournament-admin-registration-schemas";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import {
tournamentAdminRegistrationEditPage,
tournamentAdminRegistrationPage,
} from "~/utils/urls";
import {
counterpickMap,
pickCounterpickMaps,
pickedCounterpickMap,
} from "../../helpers/counterpick-map-pool";
import {
navigate,
selectTournament,
selectUser,
submit,
} from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** `/to/:id/admin/registration` (add new team) and `/to/:id/admin/registration/:tid` (edit). */
export class TournamentAdminRegistrationPage {
private readonly page: Page;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, adminRegistrationFormSchema);
this.locators = {
editHeading: page.getByRole("heading", { name: "Edit registration" }),
addHeading: page.getByRole("heading", { name: "Add new team" }),
teamNameInput: page.getByLabel("Team name"),
importTeamButton: page.getByRole("button", { name: "Import team" }),
importDialogHeading: page.getByRole("heading", { name: "Import team" }),
invalidMapPoolError: page.getByText("Invalid map pool"),
};
}
async gotoNew(tournamentId: number) {
await navigate({
page: this.page,
url: tournamentAdminRegistrationPage(tournamentId),
});
}
async gotoEdit(tournamentId: number, tournamentTeamId: number) {
await navigate({
page: this.page,
url: tournamentAdminRegistrationEditPage(tournamentId, tournamentTeamId),
});
}
/** Selects a user into the roster's placeholder member row. */
async selectPlayer(userName: string) {
await selectUser({ page: this.page, userName, labelName: "Player" });
}
/** Adds a new member row to an existing roster and selects a user into it. */
async addMember(userName: string) {
await this.page.getByRole("button", { name: "Add", exact: true }).click();
await this.page.getByLabel("Player").last().click();
await this.page.getByTestId("user-search-input").fill(userName);
await expect(
this.page.getByTestId("user-search-item").first(),
).toBeVisible();
await this.page.keyboard.press("Enter");
}
/** Names the roster member at `index` for tournaments. Only shown to organizers who may set it. */
async setTournamentName(index: number, name: string) {
await this.page.getByLabel("Tournament name").nth(index).fill(name);
}
async selectCaptain(userId: number) {
await this.page
.getByLabel("Captain", { exact: true })
.selectOption(String(userId));
}
save() {
return submit(this.page);
}
/** Picks the required amount of counterpick maps for every mode, skipping banned ones. */
pickCounterpickMaps() {
return pickCounterpickMaps(this.page);
}
pickCounterpickMap(mode: ModeShort, stageId: StageId) {
return counterpickMap(this.page, mode, stageId).click();
}
unpickCounterpickMap(mode: ModeShort, stageId: StageId) {
return pickedCounterpickMap(this.page, mode, stageId).click();
}
pickedCounterpickMap(mode: ModeShort, stageId: StageId) {
return pickedCounterpickMap(this.page, mode, stageId);
}
async openImportDialog() {
await this.locators.importTeamButton.click();
}
/** Picks the source tournament; its team `<select>` populates asynchronously
* from the import loader and auto-selects the first team. */
async importFirstTeamFrom(tournamentQuery: string) {
const dialog = this.page.getByRole("dialog");
await selectTournament({ page: this.page, query: tournamentQuery });
const teamSelect = dialog.getByLabel("Team", { exact: true });
await expect(teamSelect.locator("option")).not.toHaveCount(0);
await dialog.getByTestId("submit-button").click();
}
}

View File

@@ -0,0 +1,64 @@
import type { Page } from "@playwright/test";
import { tournamentAdminPage } from "~/utils/urls";
import {
navigate,
selectUser,
waitForPOSTResponse,
} from "../../helpers/playwright";
/** `/to/:id/admin/staff` */
export class TournamentAdminStaffPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
authorRow: page.getByTestId("staff-author"),
editButton: page.getByTestId("edit-staff-button"),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: `${tournamentAdminPage(tournamentId)}/staff`,
});
}
staffRow(username: string) {
return this.page.getByTestId(`staff-row-${username}`);
}
/** The staff section is read-only until "Edit" reveals the form. */
async edit() {
await this.locators.editButton.click();
}
/** The form collapses back to the read-only view once the save POST lands. */
async save() {
await waitForPOSTResponse(this.page, async () => {
await this.page.getByTestId("submit-button").click();
});
}
/** The empty staff array already renders one placeholder row, so the user is selected into it. */
async addStaffer(username: string, role?: string) {
await this.edit();
await selectUser({
page: this.page,
userName: username,
labelName: "User",
});
if (role) {
await this.page.getByLabel("Role", { exact: true }).selectOption(role);
}
await this.save();
}
async removeStaffer() {
await this.edit();
await this.page.getByRole("button", { name: "Remove item" }).click();
await this.save();
}
}

View File

@@ -0,0 +1,41 @@
import type { Page } from "@playwright/test";
import { tournamentAdminPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
/** `/to/:id/admin/stream` — the cast Twitch accounts form. */
export class TournamentAdminStreamPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
accountInputs: page.getByPlaceholder("dappleproductions"),
addAccountButton: page.getByRole("button", { name: "Add", exact: true }),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: `${tournamentAdminPage(tournamentId)}/stream`,
});
}
accountInput(nth: number) {
return this.locators.accountInputs.nth(nth);
}
/** An empty array field already renders one placeholder input to fill. */
async fillAccount(nth: number, twitchAccount: string) {
await this.accountInput(nth).fill(twitchAccount);
}
async addAccountField() {
await this.locators.addAccountButton.click();
}
save() {
return submit(this.page, "save-cast-twitch-accounts-button");
}
}

View File

@@ -0,0 +1,277 @@
import type { Page } from "@playwright/test";
import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
import {
expect,
modalClickConfirmButton,
navigate,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { TournamentMatchPage } from "./tournament-match-page";
import { TournamentNav } from "./tournament-nav";
export class TournamentBracketsPage {
private readonly page: Page;
readonly nav;
readonly locators;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
this.locators = {
bracketsViewer: page.getByTestId("brackets-viewer"),
showResultsButton: page.getByRole("button", { name: "Show results" }),
hideResultsButton: page.getByRole("button", { name: "Hide results" }),
censoredTeamNames: page.getByText("???"),
matches: page.locator("[data-match-id]"),
rrStandingsTables: page.getByTestId("rr-standings-table"),
liveBadges: page.getByTestId("brackets-viewer").getByText("LIVE"),
castBadges: page.getByTestId("brackets-viewer").getByText("CAST"),
streamPopover: page.getByTestId("stream-popover"),
streamPopoverStreams: page.getByTestId("tournament-stream"),
finalizeTournamentButton: page.getByTestId("finalize-tournament-button"),
finalizeBracketButton: page.getByTestId("finalize-bracket-button"),
teamsPendingFromSourcesText: page.getByText(
"Teams pending from the source brackets",
),
startRoundButton: page.getByTestId("start-round-button"),
byeTeam: page.getByTestId("bye-team"),
prepareMapsButton: page.getByTestId("prepare-maps-button"),
preparedMapsCheckIcon: page.getByTestId("prepared-maps-check-icon"),
waitingOnGroupText: page.getByText("Waiting on group to finish"),
};
}
async goto(tournamentId: number, bracketIdx?: number) {
await navigate({
page: this.page,
url: tournamentBracketsPage({ tournamentId, bracketIdx }),
});
}
teamName(name: string) {
return this.page.getByText(name);
}
/** The tab labels drop the "bracket" suffix of the bracket's name. */
bracketTab(name: string) {
return this.page.getByRole("tab", { name });
}
roundLabel(roundNumber: number) {
return this.page.getByText(`Round ${roundNumber}`, { exact: true });
}
match(matchId: number) {
return this.page.locator(`[data-match-id="${matchId}"]`);
}
/** The match's countdown timer, a sibling of the match link. */
matchTimer(matchId: number) {
return this.match(matchId).locator("..").getByTestId("bracket-match-timer");
}
/** Team names of the bracket's first round in bracket order, top to bottom; two
* slots per match, both slots of a BYE match reading as an empty string.
* `teamCount` is how many teams the first round holds, byes excluded. Note that a
* first round of mostly byes is compacted away by the UI, in which case what is
* returned is the compacted column rather than the true first round. */
async firstRoundTeamNames(teamCount: number): Promise<string[]> {
const firstRound = this.locators.bracketsViewer
.locator("[data-round-id]")
.first();
await expect(firstRound.getByTestId("match-team-name")).toHaveCount(
teamCount,
);
// a BYE renders no match rows at all, so its two slots are filled in here to
// keep the returned names aligned with the bracket's slots
return firstRound
.locator('[data-testid="match-bye"], [data-testid="match-team-name"]')
.evaluateAll((elements) =>
elements.flatMap((element) =>
element.dataset.testid === "match-bye"
? ["", ""]
: [element.textContent ?? ""],
),
);
}
/** Team names of every group's standings table, in placement order per group. */
async groupStandingsTeamNames(groupCount: number): Promise<string[][]> {
await expect(this.locators.rrStandingsTables).toHaveCount(groupCount);
const result: string[][] = [];
for (const table of await this.locators.rrStandingsTables.all()) {
result.push(await table.locator("td:first-child a").allTextContents());
}
return result;
}
participantInRound(roundId: number, tournamentTeamId: number) {
return this.page.locator(
`[data-round-id="${roundId}"] [data-participant-id="${tournamentTeamId}"]`,
);
}
/** Starts the bracket, going through the map list dialog with its defaults. */
async finalize() {
await this.page.getByTestId("finalize-bracket-button").click();
await submit(this.page, "confirm-finalize-bracket-button");
}
/** Opens the map list dialog of the finalize bracket button, for starting the
* bracket with non-default options. */
async openFinalizeDialog() {
await this.page.getByTestId("finalize-bracket-button").click();
return new BracketMapListDialog(this.page);
}
/** The same map list dialog, opened for preparing maps ahead of the start. */
async openPrepareMapsDialog() {
await this.locators.prepareMapsButton.click();
return new BracketMapListDialog(this.page);
}
async openFinalizeTournamentDialog() {
await this.locators.finalizeTournamentButton.click();
return new FinalizeTournamentDialog(this.page);
}
async openMatch(matchId: number) {
await expect(async () => {
await this.match(matchId).click();
await expect(
this.page.getByTestId("back-to-bracket-button"),
).toBeVisible();
}).toPass();
return new TournamentMatchPage(this.page);
}
/** Copies the sub invite link of the own team and reads it off the clipboard. */
async copySubInviteLink(): Promise<string> {
await this.page.getByTestId("add-sub-button").click();
await this.page.getByTestId("copy-invite-link-button").click();
return this.page.evaluate("navigator.clipboard.readText()");
}
/** Checks the own team in to a bracket that requires a check-in. */
checkInBracket() {
return submit(this.page, "check-in-bracket-button");
}
async openGroup(letter: string) {
await this.page.getByTestId(`group-${letter}-button`).click();
}
startRound() {
return submit(this.page, "start-round-button");
}
async resetRound() {
await this.page.getByTestId("reset-round-button").click();
await modalClickConfirmButton(this.page);
}
}
/** The map list dialog used both for starting a bracket and preparing its maps. */
class BracketMapListDialog {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
pickBanSelect: page.getByLabel("Pick/ban"),
countTypeSelect: page.getByLabel("Count type", { exact: true }),
expectedTeamsSelect: page.getByLabel("Expected teams"),
increaseMapCountButtons: page.getByTestId("increase-map-count-button"),
unlinkFinalsButton: page.getByTestId(
"unlink-finals-3rd-place-match-button",
),
linkFinalsButton: page.getByTestId("link-finals-3rd-place-match-button"),
beforeSetText: page.getByText("Before set"),
};
}
async setPickBan(value: string) {
await this.locators.pickBanSelect.selectOption(value);
}
async setCountType(value: string) {
await this.locators.countTypeSelect.selectOption(value);
}
async setExpectedTeams(count: number) {
await this.locators.expectedTeamsSelect.selectOption(String(count));
}
async increaseMapCount(position: "first" | "last") {
await this.locators.increaseMapCountButtons[position]().click();
}
async unlinkFinalsThirdPlaceMatch() {
await this.locators.unlinkFinalsButton.click();
}
confirm() {
return submit(this.page, "confirm-finalize-bracket-button");
}
/** Starts the bracket with a CUSTOM pick/ban flow. The flow builder UI has no
* e2e-friendly handles, so the flow is written straight into the form's maps
* input before submitting it. */
confirmWithCustomFlow(customFlow: unknown) {
return waitForPOSTResponse(this.page, async () => {
await this.page.evaluate((cfStr) => {
const input = document.querySelector(
'input[name="maps"]',
) as HTMLInputElement;
const maps = JSON.parse(input.value);
const cf = JSON.parse(cfStr);
for (const m of maps) {
if (m.pickBan === "CUSTOM") {
m.customFlow = cf;
}
}
input.value = JSON.stringify(maps);
const form = input.closest("form")!;
const btn = document.createElement("button");
btn.type = "submit";
btn.name = "_action";
btn.value = "START_BRACKET";
btn.style.display = "none";
form.appendChild(btn);
btn.click();
}, JSON.stringify(customFlow));
});
}
}
/** The dialog behind the finalize tournament button: badge assignment and confirm. */
class FinalizeTournamentDialog {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
assignBadgesLaterSwitch: page.getByTestId("assign-badges-later-switch"),
};
}
async selectBadgeReceiver(nth: number, tournamentTeamId: number) {
await this.page
.getByLabel("Receiving team")
.nth(nth)
.selectOption(String(tournamentTeamId));
}
async assignBadgesLater() {
await this.locators.assignBadgesLaterSwitch.click();
}
confirm() {
return modalClickConfirmButton(this.page);
}
}

View File

@@ -0,0 +1,24 @@
import type { Page } from "@playwright/test";
import { tournamentInfoPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { TournamentNav } from "./tournament-nav";
/** `/to/:id/info` */
export class TournamentInfoPage {
private readonly page: Page;
readonly nav;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
}
async goto(tournamentId: number) {
await navigate({ page: this.page, url: tournamentInfoPage(tournamentId) });
}
/** Matched on the machine-readable ISO attribute, making it timezone-agnostic. */
startTime(date: Date) {
return this.page.locator(`time[datetime="${date.toISOString()}"]`);
}
}

View File

@@ -0,0 +1,23 @@
import type { Page } from "@playwright/test";
import { navigate, submit } from "../../helpers/playwright";
import { TournamentBracketsPage } from "./tournament-brackets-page";
/** `/to/:id/join?code=...` — the page an invite link lands on. */
export class TournamentJoinPage {
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/** Invite links carry the production domain; only their path is navigated to. */
async gotoViaInviteLink(inviteLink: string) {
const url = new URL(inviteLink);
await navigate({ page: this.page, url: url.pathname + url.search });
}
async join() {
await submit(this.page);
return new TournamentBracketsPage(this.page);
}
}

View File

@@ -0,0 +1,324 @@
import type { Page } from "@playwright/test";
import { tournamentMatchPage } from "~/utils/urls";
import {
expect,
navigate,
selectWeapon,
submit,
waitForPOSTResponse,
} from "../../helpers/playwright";
import { TournamentBracketsPage } from "./tournament-brackets-page";
import { TournamentNav } from "./tournament-nav";
type Side = 1 | 2;
type RosterSide = "alpha" | "bravo";
type Tab = "action" | "admin" | "result" | "rosters";
const TAB_LABELS: Record<Tab, string> = {
action: "Action",
admin: "Admin",
result: "Result",
rosters: "Rosters",
};
/** `/to/:id/matches/:mid`. The match page splits its UI into URL-driven tabs
* (rosters/action/admin/etc.) — `openTab` handles the navigation between them. */
export class TournamentMatchPage {
private readonly page: Page;
readonly nav;
readonly locators;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
this.locators = {
roomPass: page.getByTestId("room-pass"),
backToBracketButton: page.getByTestId("back-to-bracket-button"),
activeRosterNeededText: page.getByTestId("active-roster-needed-text"),
reopenMatchButton: page.getByTestId("reopen-match-button"),
adminTab: page.getByRole("tab", { name: TAB_LABELS.admin }),
stageBanner: page.getByTestId("stage-banner"),
finalBanner: page.getByTestId("match-final"),
matchTimer: page.getByTestId("match-timer"),
screenBanned: page.getByTestId("screen-banned"),
screenAllowed: page.getByTestId("screen-allowed"),
unlockButton: page.getByRole("button", { name: "Unlock" }),
poolLabel: page.getByText("Pool", { exact: true }),
koCheckbox: page.getByLabel("KO"),
koResultText: page.getByText(/\(KO\)/).first(),
selectWinnerText: page.getByText("Select the winner"),
counterpickText: page.getByText("Counterpick", { exact: true }),
banAMapText: page.getByText(/Ban a map/),
lastBanText: page.getByText(/Ban a map \(2\/2\)/),
pickAMapText: page.getByText(/Pick a map/),
actionPanel: page.getByRole("tabpanel", { name: TAB_LABELS.action }),
rostersPanel: page.getByRole("tabpanel", { name: TAB_LABELS.rosters }),
reportWeaponsButton: page.getByRole("button", {
name: "Report used weapons",
}),
// the weapon reporter's own submit, which has no test id of its own
submitWeaponButton: page
.getByRole("button", { name: "Submit", exact: true })
.last(),
undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }),
};
}
async goto({
tournamentId,
matchId,
}: {
tournamentId: number;
matchId: number;
}) {
await navigate({
page: this.page,
url: tournamentMatchPage({ tournamentId, matchId }),
});
}
async openTab(tab: Tab) {
// When teams have more members than the minimum, the action tab is hidden
// until each team's active roster is locked in via the rosters tab. Auto-set
// any roster that's still in default-editing mode so callers can stay focused
// on the flow they actually care about.
if (tab === "action") {
await this.ensureActiveRostersSet();
}
await this.page.getByRole("tab", { name: TAB_LABELS[tab] }).click();
}
score(score: [number, number]) {
return this.page.getByText(score.join("-")).first();
}
winnerRadio(side: Side) {
return this.page.getByTestId(`winner-radio-${side}`);
}
modeProgress(mode: string) {
return this.page.getByTestId(`mode-progress-${mode}`);
}
mapCountText(count: number) {
return this.page.getByText(`×${count}`);
}
playAllText(count: number) {
return this.page.getByText(`Play all ${count}`);
}
/**
* Sweeps `mapsToReport` maps in a row, all won by `winner`. By default the
* last map ends the set (the typical case — full Bo3/Bo5 sweep), and the
* flow goes through the confirmation screen for that map. Pass
* `setEnds: false` when reporting a partial set (e.g. only 1 of a Bo3).
*/
async reportResult({
mapsToReport,
winner = 1,
setEnds = true,
}: {
mapsToReport: number;
winner?: Side;
setEnds?: boolean;
}) {
for (let i = 0; i < mapsToReport; i++) {
const isFinal = setEnds && i === mapsToReport - 1;
// Wait for the action panel to settle before clicking. waitForPOSTResponse
// only waits for the POST itself; the loader revalidation that swaps in
// the next map's component runs after, so a previous winner can still be
// `data-selected="true"` here. Clicking too early hits the about-to-unmount
// label and the selection is lost on remount.
await expect(
this.page.locator(
'[data-testid^="winner-radio-"][data-selected="true"]',
),
).toHaveCount(0);
await this.winnerRadio(winner).click();
if (isFinal) {
await this.page.getByTestId("report-score-button").click();
await submit(this.page, "confirm-set-end-button");
} else {
await submit(this.page, "report-score-button");
}
}
}
/**
* Same as `reportResult` but with the winner given by team name. Useful when
* the match up was generated by the app (swiss) and the sides are not known
* ahead of time.
*/
async reportResultForTeam({
teamName,
mapsToReport,
}: {
teamName: string;
mapsToReport: number;
}) {
await expect(this.winnerRadio(1)).toBeVisible();
const sideOneIsTeam =
(await this.winnerRadio(1).getByText(teamName, { exact: true }).count()) >
0;
return this.reportResult({ mapsToReport, winner: sideOneIsTeam ? 1 : 2 });
}
/**
* Selects one of the pick/ban options and submits it.
*
* Selecting and submitting are retried together as one unit: a re-render
* landing mid-click can swallow the submit press (see waitForPOSTResponse)
* and, if the panel remounted, the selection with it — so re-submitting
* alone would not be enough.
*/
pickBan(option: "first" | "last" = "first") {
return waitForPOSTResponse(this.page, async () => {
await this.page.getByTestId("pick-ban-button")[option]().click();
await this.page.getByTestId("pick-ban-submit-button").click();
});
}
undoLastReport() {
return waitForPOSTResponse(this.page, async () => {
await this.page.getByTestId("undo-score-button").click();
});
}
/** The weapon reporter's input for the `mapNumber`th map of the set (1-indexed). */
weaponPrompt(mapNumber: number) {
return this.page.getByText(`Your weapon #${mapNumber}`);
}
/** The weapon reporter sits collapsed behind a button unless the viewer's
* preference has it open — expanding an already open one is a no-op. */
async expandWeaponReporter() {
await expect(this.locators.actionPanel).toBeVisible();
if (await this.locators.reportWeaponsButton.isVisible()) {
await this.locators.reportWeaponsButton.click();
}
}
async reportWeapon(name: string) {
await this.expandWeaponReporter();
await selectWeapon({ page: this.page, name });
await waitForPOSTResponse(this.page, async () => {
await this.locators.submitWeaponButton.click();
});
}
playerCheckbox(side: RosterSide, nth: number) {
return this.page.getByTestId(`player-checkbox-${side}-${nth}`);
}
saveActiveRoster(side: RosterSide) {
return submit(this.page, `save-active-roster-button-${side}`);
}
editActiveRosterButton(side: RosterSide) {
return this.page.getByTestId(`edit-active-roster-button-${side}`);
}
/** Puts exactly the members at `memberIndexes` on the team's active roster,
* from the rosters tab. Starts an edit first when a roster was already set. */
async setActiveRoster(side: RosterSide, memberIndexes: number[]) {
await expect(this.locators.rostersPanel).toBeVisible();
const editButton = this.editActiveRosterButton(side);
const checkboxes = this.page.locator(
`[data-testid^="player-checkbox-${side}-"]`,
);
// A roster that was never set renders in editing mode already, one that was
// has to be put back into it.
await expect(editButton.or(checkboxes.first())).toBeVisible();
if (await editButton.isVisible()) {
await editButton.click();
await expect(checkboxes.first()).toBeVisible();
}
const memberCount = await checkboxes.count();
for (let i = 0; i < memberCount; i++) {
const checkbox = this.playerCheckbox(side, i);
if ((await checkbox.isChecked()) !== memberIndexes.includes(i)) {
await checkbox.click();
}
}
await this.saveActiveRoster(side);
}
reopen() {
return submit(this.page, "reopen-match-button");
}
editResultButton(nth: number) {
return this.page.getByTestId(`edit-result-${nth}-button`);
}
editResultPlayerCheckbox(side: RosterSide, nth: number) {
return this.page.getByTestId(`edit-result-player-checkbox-${side}-${nth}`);
}
saveResult(nth: number) {
return submit(this.page, `save-result-${nth}-button`);
}
/** Assigns a cast Twitch account to the match via the admin tab's chip radio,
* which auto-submits the cast channel on pick. */
setCastedBy(twitchAccount: string) {
return waitForPOSTResponse(this.page, async () => {
await this.page.locator(`label[for$="-${twitchAccount}"]`).click();
});
}
/** Toggles the match's lock state via the admin tab's cast info form. */
submitCastInfo() {
return submit(this.page, "cast-info-submit-button");
}
async endSetWithRandomWinner() {
await this.page.getByRole("button", { name: "End set" }).click();
await this.page.getByRole("radio", { name: /Random/ }).check();
await submit(this.page, "end-set-button");
}
async backToBracket() {
await expect(async () => {
await this.locators.backToBracketButton.click();
await expect(this.page.getByTestId("brackets-viewer")).toBeVisible();
}).toPass();
return new TournamentBracketsPage(this.page);
}
private async ensureActiveRostersSet() {
const sides: RosterSide[] = ["alpha", "bravo"];
// If the action tab is already there, no rosters need setting.
if (
(await this.page.getByRole("tab", { name: TAB_LABELS.action }).count()) >
0
) {
return;
}
// Editing inputs only render on the rosters tab — switch there.
await this.page.getByRole("tab", { name: TAB_LABELS.rosters }).click();
// Wait for the rosters panel to be ready before probing for editing UI.
await expect(this.locators.rostersPanel).toBeVisible();
for (const side of sides) {
const submitButton = this.page.getByTestId(
`save-active-roster-button-${side}`,
);
if ((await submitButton.count()) === 0) continue;
// Default-editing renders all members unchecked; pick the first 4.
for (let i = 0; i < 4; i++) {
const checkbox = this.playerCheckbox(side, i);
if (!(await checkbox.isChecked())) await checkbox.click();
}
await this.saveActiveRoster(side);
}
}
}

View File

@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";
import { clickNavTab } from "../../helpers/playwright";
import { TournamentAdminPage } from "./tournament-admin-page";
import { TournamentBracketsPage } from "./tournament-brackets-page";
import { TournamentResultsPage } from "./tournament-results-page";
/** Tabs shared by every tournament page, some of which collapse into a "More" menu. */
export class TournamentNav {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
teamsTab: page.locator('[data-testid="teams-tab"]:visible'),
registerTab: page.locator('[data-testid="register-tab"]:visible'),
};
}
async openBrackets() {
await clickNavTab(this.page, "brackets-tab");
return new TournamentBracketsPage(this.page);
}
async openAdmin() {
await clickNavTab(this.page, "admin-tab");
return new TournamentAdminPage(this.page);
}
async openResults() {
await clickNavTab(this.page, "results-tab");
return new TournamentResultsPage(this.page);
}
}

View File

@@ -0,0 +1,28 @@
import type { Page } from "@playwright/test";
import { tournamentPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { TournamentNav } from "./tournament-nav";
import { TournamentRegisterPage } from "./tournament-register-page";
export class TournamentPage {
private readonly page: Page;
readonly nav;
readonly locators;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
this.locators = {
registerCta: page.getByTestId("register-cta"),
};
}
async goto(tournamentId: number) {
await navigate({ page: this.page, url: tournamentPage(tournamentId) });
}
async register() {
await this.locators.registerCta.click();
return new TournamentRegisterPage(this.page);
}
}

View File

@@ -0,0 +1,76 @@
import type { Page } from "@playwright/test";
import { registerTeamFormSchema } from "~/features/tournament/tournament-register-schemas";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { tournamentRegisterPage } from "~/utils/urls";
import {
counterpickMap,
pickCounterpickMaps,
} from "../../helpers/counterpick-map-pool";
import { navigate, submit } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
import { TournamentNav } from "./tournament-nav";
export class TournamentRegisterPage {
private readonly page: Page;
readonly nav;
readonly form;
readonly locators;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
this.form = createFormHelpers(page, registerTeamFormSchema, {
submitTestId: "save-team-button",
});
this.locators = {
fillRosterHeading: page.getByText("Fill roster"),
registrationClosedAlert: page.getByText(
"Registration for this tournament has closed",
),
leaveTeamButton: page.getByRole("button", { name: "Leave the team" }),
organizerAddedLeaveExplanation: page.getByText(
"You were added to the team by the organizer. Contact the TO to leave the team.",
),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: tournamentRegisterPage(tournamentId),
});
}
member(number: number) {
return this.page.getByTestId(`member-num-${number}`);
}
stepCheckmark(number: number) {
return this.page.getByTestId(`checkmark-icon-num-${number}`);
}
counterpickMap(mode: ModeShort, stageId: StageId) {
return counterpickMap(this.page, mode, stageId);
}
addPlayer() {
return submit(this.page, "add-player-button");
}
/** Picks the required amount of counterpick maps for every mode, skipping banned ones. */
pickCounterpickMaps() {
return pickCounterpickMaps(this.page);
}
saveCounterpickMaps() {
return submit(this.page, "save-map-list-button");
}
checkIn() {
return submit(this.page, "check-in-button");
}
openBrackets() {
return this.nav.openBrackets();
}
}

View File

@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import { tournamentResultsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { UserPage } from "../user/user-page";
/** `/to/:id/results` */
export class TournamentResultsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
showResultsButton: page.getByRole("button", { name: "Show results" }),
resultTeamNames: page.getByTestId("result-team-name"),
teamMemberNames: page.getByTestId("team-member-name"),
// seed performance rating, shown only after the tournament is finalized
sprHeader: page.getByTestId("spr-header"),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: tournamentResultsPage(tournamentId),
});
}
/** Expands a team's row so its members show. */
async expandTeam(nth: number) {
await this.locators.resultTeamNames.nth(nth).click();
}
async openMember(nth: number) {
await this.locators.teamMemberNames.nth(nth).click();
return new UserPage(this.page);
}
}

View File

@@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
import { tournamentRulesPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import { TournamentNav } from "./tournament-nav";
/** `/to/:id/rules`, showing the tournament's map pool. */
export class TournamentRulesPage {
private readonly page: Page;
readonly nav;
constructor(page: Page) {
this.page = page;
this.nav = new TournamentNav(page);
}
async goto(tournamentId: number) {
await navigate({ page: this.page, url: tournamentRulesPage(tournamentId) });
}
stageName(stage: string) {
return this.page.getByText(stage, { exact: true });
}
modeImage(mode: string) {
return this.page.getByAltText(mode);
}
}

View File

@@ -0,0 +1,73 @@
import type { Page } from "@playwright/test";
import { tournamentAdminPage } from "~/utils/urls";
import { navigate, submit } from "../../helpers/playwright";
const DRAG_TARGET_Y = 500;
export class TournamentSeedsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
setAbDivisionsButton: page.getByTestId("set-ab-divisions"),
abDivisionRadioGroups: page.getByTestId("ab-division-radio-group"),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: `${tournamentAdminPage(tournamentId)}/seeds`,
});
}
teamHandle(tournamentTeamId: number) {
return this.page.getByTestId(`seed-team-${tournamentTeamId}-handle`);
}
/** Drags a team down the seeding list, past the teams seeded below it. */
async dragTeamDown(tournamentTeamId: number) {
await this.teamHandle(tournamentTeamId).hover();
await this.page.mouse.down();
// i think the drag & drop library might actually be a bit buggy
// so we have to do it in steps like this to allow for testing
await this.page.mouse.move(0, DRAG_TARGET_Y, { steps: 10 });
await this.page.mouse.up();
}
save() {
return submit(this.page);
}
async openAbDivisionsDialog() {
await this.locators.setAbDivisionsButton.click();
}
async openStartingBracketsDialog() {
await this.page.getByTestId("set-starting-brackets").click();
}
async setStartingBracket(nth: number, bracketName: string) {
await this.page
.getByTestId("starting-bracket-select")
.nth(nth)
.selectOption(bracketName);
}
saveStartingBrackets() {
return submit(this.page, "set-starting-brackets-submit-button");
}
async assignAbDivision(nth: number, division: "A" | "B") {
await this.locators.abDivisionRadioGroups
.nth(nth)
.getByText(division, { exact: true })
.click();
}
saveAbDivisions() {
return submit(this.page, "set-ab-divisions-submit-button");
}
}

View File

@@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
import { tournamentStreamsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/to/:id/streams` */
export class TournamentStreamsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
streams: page.getByTestId("tournament-stream"),
};
}
async goto(tournamentId: number) {
await navigate({
page: this.page,
url: tournamentStreamsPage(tournamentId),
});
}
viewerCount(count: number) {
return this.page.getByText(String(count));
}
}

Some files were not shown because too many files have changed in this diff Show More