mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
70
apps/web-react/e2e/analyzer.spec.ts
Normal file
70
apps/web-react/e2e/analyzer.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
468
apps/web-react/e2e/api-public.spec.ts
Normal file
468
apps/web-react/e2e/api-public.spec.ts
Normal 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;
|
||||
}
|
||||
84
apps/web-react/e2e/art.spec.ts
Normal file
84
apps/web-react/e2e/art.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
apps/web-react/e2e/associations.spec.ts
Normal file
66
apps/web-react/e2e/associations.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
apps/web-react/e2e/badges.spec.ts
Normal file
53
apps/web-react/e2e/badges.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
apps/web-react/e2e/ban.spec.ts
Normal file
68
apps/web-react/e2e/ban.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
132
apps/web-react/e2e/builds.spec.ts
Normal file
132
apps/web-react/e2e/builds.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
278
apps/web-react/e2e/calendar.spec.ts
Normal file
278
apps/web-react/e2e/calendar.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
166
apps/web-react/e2e/comp-analyzer.spec.ts
Normal file
166
apps/web-react/e2e/comp-analyzer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
apps/web-react/e2e/events.spec.ts
Normal file
60
apps/web-react/e2e/events.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
BIN
apps/web-react/e2e/fixtures/test-image.png
Normal file
BIN
apps/web-react/e2e/fixtures/test-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1006 B |
35
apps/web-react/e2e/friends.spec.ts
Normal file
35
apps/web-react/e2e/friends.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
apps/web-react/e2e/global-search.spec.ts
Normal file
49
apps/web-react/e2e/global-search.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
289
apps/web-react/e2e/global-setup.ts
Normal file
289
apps/web-react/e2e/global-setup.ts
Normal 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;
|
||||
59
apps/web-react/e2e/global-teardown.ts
Normal file
59
apps/web-react/e2e/global-teardown.ts
Normal 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;
|
||||
45
apps/web-react/e2e/helpers/counterpick-map-pool.ts
Normal file
45
apps/web-react/e2e/helpers/counterpick-map-pool.ts
Normal 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;
|
||||
}
|
||||
135
apps/web-react/e2e/helpers/factories.ts
Normal file
135
apps/web-react/e2e/helpers/factories.ts
Normal 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)",
|
||||
);
|
||||
}
|
||||
273
apps/web-react/e2e/helpers/playwright-form.ts
Normal file
273
apps/web-react/e2e/helpers/playwright-form.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
365
apps/web-react/e2e/helpers/playwright.ts
Normal file
365
apps/web-react/e2e/helpers/playwright.ts
Normal 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();
|
||||
}
|
||||
73
apps/web-react/e2e/helpers/sidebar.ts
Normal file
73
apps/web-react/e2e/helpers/sidebar.ts
Normal 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));
|
||||
}
|
||||
293
apps/web-react/e2e/helpers/tournament.ts
Normal file
293
apps/web-react/e2e/helpers/tournament.ts
Normal 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 };
|
||||
}
|
||||
83
apps/web-react/e2e/lfg.spec.ts
Normal file
83
apps/web-react/e2e/lfg.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
77
apps/web-react/e2e/navigation.spec.ts
Normal file
77
apps/web-react/e2e/navigation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
apps/web-react/e2e/notifications.spec.ts
Normal file
38
apps/web-react/e2e/notifications.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
apps/web-react/e2e/object-damage-calculator.spec.ts
Normal file
77
apps/web-react/e2e/object-damage-calculator.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
208
apps/web-react/e2e/org.spec.ts
Normal file
208
apps/web-react/e2e/org.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
87
apps/web-react/e2e/pages/analyzer/analyzer-page.ts
Normal file
87
apps/web-react/e2e/pages/analyzer/analyzer-page.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
77
apps/web-react/e2e/pages/analyzer/weapon-params-page.ts
Normal file
77
apps/web-react/e2e/pages/analyzer/weapon-params-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
52
apps/web-react/e2e/pages/api/api-page.ts
Normal file
52
apps/web-react/e2e/pages/api/api-page.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
40
apps/web-react/e2e/pages/art/new-art-page.ts
Normal file
40
apps/web-react/e2e/pages/art/new-art-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
apps/web-react/e2e/pages/art/user-art-page.ts
Normal file
30
apps/web-react/e2e/pages/art/user-art-page.ts
Normal 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)}"]`);
|
||||
}
|
||||
}
|
||||
59
apps/web-react/e2e/pages/associations/associations-page.ts
Normal file
59
apps/web-react/e2e/pages/associations/associations-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
66
apps/web-react/e2e/pages/badges/badge-page.ts
Normal file
66
apps/web-react/e2e/pages/badges/badge-page.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
apps/web-react/e2e/pages/ban/admin-ban-page.ts
Normal file
91
apps/web-react/e2e/pages/ban/admin-ban-page.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
apps/web-react/e2e/pages/ban/suspended-page.ts
Normal file
20
apps/web-react/e2e/pages/ban/suspended-page.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
21
apps/web-react/e2e/pages/builds/build-card.ts
Normal file
21
apps/web-react/e2e/pages/builds/build-card.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
apps/web-react/e2e/pages/builds/build-form-page.ts
Normal file
47
apps/web-react/e2e/pages/builds/build-form-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/web-react/e2e/pages/builds/builds-page.ts
Normal file
25
apps/web-react/e2e/pages/builds/builds-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
44
apps/web-react/e2e/pages/builds/user-builds-page.ts
Normal file
44
apps/web-react/e2e/pages/builds/user-builds-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
apps/web-react/e2e/pages/builds/weapon-builds-page.ts
Normal file
62
apps/web-react/e2e/pages/builds/weapon-builds-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
25
apps/web-react/e2e/pages/calendar/calendar-event-page.ts
Normal file
25
apps/web-react/e2e/pages/calendar/calendar-event-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
136
apps/web-react/e2e/pages/calendar/calendar-new-event-page.ts
Normal file
136
apps/web-react/e2e/pages/calendar/calendar-new-event-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
101
apps/web-react/e2e/pages/calendar/calendar-page.ts
Normal file
101
apps/web-react/e2e/pages/calendar/calendar-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
43
apps/web-react/e2e/pages/calendar/events-page.ts
Normal file
43
apps/web-react/e2e/pages/calendar/events-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
apps/web-react/e2e/pages/calendar/report-winners-page.ts
Normal file
55
apps/web-react/e2e/pages/calendar/report-winners-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
apps/web-react/e2e/pages/comp-analyzer/comp-analyzer-page.ts
Normal file
98
apps/web-react/e2e/pages/comp-analyzer/comp-analyzer-page.ts
Normal 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']");
|
||||
}
|
||||
}
|
||||
75
apps/web-react/e2e/pages/friends/friends-page.ts
Normal file
75
apps/web-react/e2e/pages/friends/friends-page.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
apps/web-react/e2e/pages/front-page/front-page.ts
Normal file
27
apps/web-react/e2e/pages/front-page/front-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
apps/web-react/e2e/pages/img-upload/image-validation-page.ts
Normal file
29
apps/web-react/e2e/pages/img-upload/image-validation-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
32
apps/web-react/e2e/pages/layout/anything-adder.ts
Normal file
32
apps/web-react/e2e/pages/layout/anything-adder.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
77
apps/web-react/e2e/pages/layout/mobile-nav.ts
Normal file
77
apps/web-react/e2e/pages/layout/mobile-nav.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
43
apps/web-react/e2e/pages/layout/notification-popover.ts
Normal file
43
apps/web-react/e2e/pages/layout/notification-popover.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
137
apps/web-react/e2e/pages/layout/side-nav.ts
Normal file
137
apps/web-react/e2e/pages/layout/side-nav.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
30
apps/web-react/e2e/pages/layout/top-nav-menus.ts
Normal file
30
apps/web-react/e2e/pages/layout/top-nav-menus.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
apps/web-react/e2e/pages/lfg/lfg-page.ts
Normal file
55
apps/web-react/e2e/pages/lfg/lfg-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
apps/web-react/e2e/pages/lfg/new-lfg-post-page.ts
Normal file
32
apps/web-react/e2e/pages/lfg/new-lfg-post-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/web-react/e2e/pages/notifications/notifications-page.ts
Normal file
21
apps/web-react/e2e/pages/notifications/notifications-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
23
apps/web-react/e2e/pages/org/new-organization-page.ts
Normal file
23
apps/web-react/e2e/pages/org/new-organization-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/web-react/e2e/pages/org/organization-edit-page.ts
Normal file
38
apps/web-react/e2e/pages/org/organization-edit-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
96
apps/web-react/e2e/pages/org/organization-page.ts
Normal file
96
apps/web-react/e2e/pages/org/organization-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
70
apps/web-react/e2e/pages/scrims/new-scrim-post-page.ts
Normal file
70
apps/web-react/e2e/pages/scrims/new-scrim-post-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
142
apps/web-react/e2e/pages/scrims/scrim-page.ts
Normal file
142
apps/web-react/e2e/pages/scrims/scrim-page.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
168
apps/web-react/e2e/pages/scrims/scrims-page.ts
Normal file
168
apps/web-react/e2e/pages/scrims/scrims-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
apps/web-react/e2e/pages/search/global-search-dialog.ts
Normal file
47
apps/web-react/e2e/pages/search/global-search-dialog.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
apps/web-react/e2e/pages/sendouq/group-card.ts
Normal file
53
apps/web-react/e2e/pages/sendouq/group-card.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
apps/web-react/e2e/pages/sendouq/sendouq-looking-page.ts
Normal file
48
apps/web-react/e2e/pages/sendouq/sendouq-looking-page.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
234
apps/web-react/e2e/pages/sendouq/sendouq-match-page.ts
Normal file
234
apps/web-react/e2e/pages/sendouq/sendouq-match-page.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
46
apps/web-react/e2e/pages/sendouq/sendouq-page.ts
Normal file
46
apps/web-react/e2e/pages/sendouq/sendouq-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
apps/web-react/e2e/pages/sendouq/sendouq-preparing-page.ts
Normal file
54
apps/web-react/e2e/pages/sendouq/sendouq-preparing-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
apps/web-react/e2e/pages/sendouq/sendouq-ready-page.ts
Normal file
34
apps/web-react/e2e/pages/sendouq/sendouq-ready-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
32
apps/web-react/e2e/pages/settings/match-profile-page.ts
Normal file
32
apps/web-react/e2e/pages/settings/match-profile-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
91
apps/web-react/e2e/pages/settings/settings-page.ts
Normal file
91
apps/web-react/e2e/pages/settings/settings-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/web-react/e2e/pages/team/join-team-page.ts
Normal file
21
apps/web-react/e2e/pages/team/join-team-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
apps/web-react/e2e/pages/team/new-team-page.ts
Normal file
19
apps/web-react/e2e/pages/team/new-team-page.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
43
apps/web-react/e2e/pages/team/team-edit-page.ts
Normal file
43
apps/web-react/e2e/pages/team/team-edit-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
92
apps/web-react/e2e/pages/team/team-page.ts
Normal file
92
apps/web-react/e2e/pages/team/team-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
88
apps/web-react/e2e/pages/team/team-roster-page.ts
Normal file
88
apps/web-react/e2e/pages/team/team-roster-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
106
apps/web-react/e2e/pages/tier-list-maker/tier-list-maker-page.ts
Normal file
106
apps/web-react/e2e/pages/tier-list-maker/tier-list-maker-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
14
apps/web-react/e2e/pages/top-search/placements-table.ts
Normal file
14
apps/web-react/e2e/pages/top-search/placements-table.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
55
apps/web-react/e2e/pages/top-search/top-search-page.ts
Normal file
55
apps/web-react/e2e/pages/top-search/top-search-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
163
apps/web-react/e2e/pages/tournament/tournament-admin-page.ts
Normal file
163
apps/web-react/e2e/pages/tournament/tournament-admin-page.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
277
apps/web-react/e2e/pages/tournament/tournament-brackets-page.ts
Normal file
277
apps/web-react/e2e/pages/tournament/tournament-brackets-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
apps/web-react/e2e/pages/tournament/tournament-info-page.ts
Normal file
24
apps/web-react/e2e/pages/tournament/tournament-info-page.ts
Normal 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()}"]`);
|
||||
}
|
||||
}
|
||||
23
apps/web-react/e2e/pages/tournament/tournament-join-page.ts
Normal file
23
apps/web-react/e2e/pages/tournament/tournament-join-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
324
apps/web-react/e2e/pages/tournament/tournament-match-page.ts
Normal file
324
apps/web-react/e2e/pages/tournament/tournament-match-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/web-react/e2e/pages/tournament/tournament-nav.ts
Normal file
34
apps/web-react/e2e/pages/tournament/tournament-nav.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
apps/web-react/e2e/pages/tournament/tournament-page.ts
Normal file
28
apps/web-react/e2e/pages/tournament/tournament-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
27
apps/web-react/e2e/pages/tournament/tournament-rules-page.ts
Normal file
27
apps/web-react/e2e/pages/tournament/tournament-rules-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
apps/web-react/e2e/pages/tournament/tournament-seeds-page.ts
Normal file
73
apps/web-react/e2e/pages/tournament/tournament-seeds-page.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user