Finish other

This commit is contained in:
Kalle 2026-07-29 17:58:50 +03:00
parent 522c5b3977
commit 37ac6aa4a9
20 changed files with 1407 additions and 926 deletions

View File

@ -1,49 +1,24 @@
import type { Page } from "@playwright/test";
import { ORG_ADMIN_TEST_ID } from "~/db/seed/constants";
import { addHours } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { tournamentTeamPage } from "~/utils/urls";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
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 ITZ_TOURNAMENT_ID = 2;
const ITZ_TEAM_ID = 101;
const USER_NOT_ON_ITZ_TEAM = 100;
const ROSTER_SIZE = 4;
const TOKEN_LENGTH = 20;
async function generateReadToken(page: Page): Promise<string> {
await navigate({ page, url: "/api" });
await page.locator("form").first().getByRole("button").click();
await page.waitForURL("/api");
await page
.getByRole("button", { name: /reveal/i })
.first()
.click();
const token = await page.locator("input[readonly]").inputValue();
return token;
}
async function generateWriteToken(page: Page): Promise<string> {
await navigate({ page, url: "/api" });
// Click the second form (write token)
await page.locator("form").nth(1).getByRole("button").click();
await page.waitForURL("/api");
// Reveal and get the write token
// After generating only the write token, there's just one reveal button (for write)
await page.getByRole("button", { name: /reveal/i }).click();
const token = await page.locator("input[readonly]").inputValue();
return token;
}
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,
}) => {
// await seed(page);
const response = await page.request.fetch("/api/tournament/1", {
method: "OPTIONS",
});
@ -57,8 +32,6 @@ test.describe("Public API", () => {
});
test("GET request includes CORS headers in response", async ({ page }) => {
// await seed(page);
const response = await page.request.fetch("/api/tournament/1");
expect(response.headers()["access-control-allow-origin"]).toBe("*");
@ -67,8 +40,6 @@ test.describe("Public API", () => {
test("GET user IDs endpoint works without authentication", async ({
page,
}) => {
// await seed(page);
const response = await page.request.fetch(`/api/user/${ADMIN_ID}/ids`);
expect(response.status()).toBe(200);
@ -77,28 +48,22 @@ test.describe("Public API", () => {
expect(data.discordId).toBeTruthy();
});
test("creates read API token and calls public endpoint", async ({ page }) => {
// await seed(page);
test("creates read API token and calls public endpoint", async ({
page,
factories,
}) => {
await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] });
await impersonate(page);
await navigate({ page, url: "/api" });
const api = new ApiPage(page);
await api.goto();
await page.locator("form").first().getByRole("button").click();
await page.waitForURL("/api");
await page
.getByRole("button", { name: /reveal/i })
.first()
.click();
const token = await page.locator("input[readonly]").inputValue();
expect(token).toBeTruthy();
expect(token.length).toBe(20);
const token = await api.generateToken("read");
expect(token).toHaveLength(TOKEN_LENGTH);
const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, {
headers: {
Authorization: `Bearer ${token}`,
},
headers: authorized(token),
});
expect(response.status()).toBe(200);
@ -107,24 +72,25 @@ test.describe("Public API", () => {
expect(data.name).toBe("Sendou");
});
test("returns active SendouQ match for user", async ({ page }) => {
// await seed(page, "IN_SQ_MATCH");
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 token = await generateReadToken(page);
const response = await page.request.fetch(
`/api/user/${ADMIN_ID}/active-match`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
`/api/user/${alpha[0].id}/active-match`,
{ headers: authorized(token) },
);
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.matchId).toEqual(expect.any(Number));
expect(data.matchId).toBe(match.id);
expect(data.lobby).toBe("sendouq");
expect(data.tournamentId).toBeNull();
expect(data.bracketIdx).toBeNull();
@ -132,100 +98,83 @@ test.describe("Public API", () => {
});
test.describe("Public API - Write endpoints", () => {
test("adds member to tournament team via API", async ({ page }) => {
// await seed(page);
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 token = await generateWriteToken(page);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(200);
// Verify in UI that member was added
await navigate({
page,
url: tournamentTeamPage({
tournamentId: ITZ_TOURNAMENT_ID,
tournamentTeamId: ITZ_TEAM_ID,
}),
});
const teamPage = new TournamentTeamPage(page);
await teamPage.goto(tournamentId, teamId);
// User 100 should be visible on the team page
await expect(page.getByTestId("team-member-name")).toHaveCount(5);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1);
});
test("removes member from tournament team via API", async ({ page }) => {
// await seed(page);
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);
const token = await generateWriteToken(page);
// First add the member
await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized(token),
data: { userId: newMember.id },
},
);
// Verify member was added
await navigate({
page,
url: tournamentTeamPage({
tournamentId: ITZ_TOURNAMENT_ID,
tournamentTeamId: ITZ_TEAM_ID,
}),
});
await expect(page.getByTestId("team-member-name")).toHaveCount(5);
const teamPage = new TournamentTeamPage(page);
await teamPage.goto(tournamentId, teamId);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1);
// Remove the member via API
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/remove-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/remove-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized(token),
data: { userId: newMember.id },
},
);
expect(response.status()).toBe(200);
// Verify in UI that member was removed
await page.reload();
await expect(page.getByTestId("team-member-name")).toHaveCount(4);
await teamPage.goto(tournamentId, teamId);
await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE);
});
test("returns 401 for invalid token", async ({ page }) => {
// await seed(page);
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/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: {
Authorization: "Bearer invalid-token-12345",
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized("invalid-token-12345"),
data: { userId: newMember.id },
},
);
@ -236,35 +185,20 @@ test.describe("Public API - Write endpoints", () => {
test("returns 403 when using read token for write endpoint", async ({
page,
factories,
}) => {
// await seed(page);
const { tournamentId, teamId } = await organizedTournament(factories);
const newMember = await factories.UserFactory.create();
const token = await readToken(factories, ADMIN_ID);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: "/api" });
// Click the first form (read token)
await page.locator("form").first().getByRole("button").click();
await page.waitForURL("/api");
// Reveal and get the read token
await page
.getByRole("button", { name: /reveal/i })
.first()
.click();
const readToken = await page
.locator("input[readonly]")
.first()
.inputValue();
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${readToken}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized(token),
data: { userId: newMember.id },
},
);
@ -273,31 +207,28 @@ test.describe("Public API - Write endpoints", () => {
expect(data.error).toBe("Write token required");
});
test("updates tournament seeds via API", async ({ page }) => {
// await seed(page);
test("updates tournament seeds via API", async ({ page, factories }) => {
const { tournamentId, token } = await organizedTournament(factories, {
teamCount: 3,
});
await impersonate(page, ADMIN_ID);
const token = await generateWriteToken(page);
const teamsResponse = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams`,
{
headers: { Authorization: `Bearer ${token}` },
},
`/api/tournament/${tournamentId}/teams`,
{ headers: authorized(token) },
);
expect(teamsResponse.status()).toBe(200);
const teams = await teamsResponse.json();
const tournamentTeamIds = teams.map((t: { id: number }) => t.id);
const reversedSeeds = [...tournamentTeamIds].reverse();
const reversedSeeds = teams
.map((team: { id: number }) => team.id)
.reverse();
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/seeds`,
`/api/tournament/${tournamentId}/seeds`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
headers: authorized(token),
data: { tournamentTeamIds: reversedSeeds },
},
);
@ -305,48 +236,36 @@ test.describe("Public API - Write endpoints", () => {
expect(response.status()).toBe(200);
const updatedTeamsResponse = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams`,
{
headers: { Authorization: `Bearer ${token}` },
},
`/api/tournament/${tournamentId}/teams`,
{ headers: authorized(token) },
);
const updatedTeams = await updatedTeamsResponse.json();
for (let i = 0; i < reversedSeeds.length; i++) {
for (const [index, tournamentTeamId] of reversedSeeds.entries()) {
const team = updatedTeams.find(
(t: { id: number }) => t.id === reversedSeeds[i],
(candidate: { id: number }) => candidate.id === tournamentTeamId,
);
expect(team.seed).toBe(i + 1);
expect(team.seed).toBe(index + 1);
}
});
test("updates tournament starting brackets via API", async ({ page }) => {
// await seed(page);
test("updates tournament starting brackets via API", async ({
page,
factories,
}) => {
const { tournamentId, teamId, token } =
await organizedTournament(factories);
await impersonate(page, ADMIN_ID);
const token = await generateWriteToken(page);
const teamsResponse = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
expect(teamsResponse.status()).toBe(200);
const teams = await teamsResponse.json();
const firstTeamId = teams[0].id;
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/starting-brackets`,
`/api/tournament/${tournamentId}/starting-brackets`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
headers: authorized(token),
data: {
startingBrackets: [
{ tournamentTeamId: firstTeamId, startingBracketIdx: 0 },
{ tournamentTeamId: teamId, startingBracketIdx: 0 },
],
},
},
@ -355,21 +274,18 @@ test.describe("Public API - Write endpoints", () => {
expect(response.status()).toBe(200);
});
test("updates member IGN via API", async ({ page }) => {
// await seed(page);
test("updates member IGN via API", async ({ page, factories }) => {
const { tournamentId, teamId, memberUserIds, token } =
await organizedTournament(factories);
await impersonate(page, ADMIN_ID);
const token = await generateWriteToken(page);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/update-member-ign`,
`/api/tournament/${tournamentId}/teams/${teamId}/update-member-ign`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: ADMIN_ID, inGameName: "NewName#9999" },
headers: authorized(token),
data: { userId: memberUserIds[0], inGameName: "NewName#9999" },
},
);
@ -378,21 +294,26 @@ test.describe("Public API - Write endpoints", () => {
test("returns 400 when user is not the organizer of this tournament", async ({
page,
factories,
}) => {
// await seed(page);
await impersonate(page, ORG_ADMIN_TEST_ID);
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",
});
const token = await generateWriteToken(page);
await impersonate(page, outsider.id);
const response = await page.request.fetch(
`/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`,
`/api/tournament/${tournamentId}/teams/${teamId}/add-member`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
data: { userId: USER_NOT_ON_ITZ_TEAM },
headers: authorized(token),
data: { userId: newMember.id },
},
);
@ -401,3 +322,50 @@ test.describe("Public API - Write endpoints", () => {
expect(data.error).toBe("Unauthorized");
});
});
/** 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 readToken(factories: Factories, userId: number) {
await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] });
const { token } = await factories.ApiTokenFactory.create({
userId,
type: "read",
});
return token;
}

View File

@ -1,135 +1,68 @@
import type { Page } from "@playwright/test";
import { addDays, setHours, startOfHour } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { ADMIN_PAGE, SUSPENDED_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
navigate,
test,
waitForPOSTResponse,
} from "./helpers/playwright";
async function banUser(
page: Page,
options: { duration?: string; reason?: string },
) {
const banForm = page
.locator("form")
.filter({ has: page.locator("h2", { hasText: /^Ban user$/ }) });
const comboboxButton = banForm.getByLabel("User");
await expect(comboboxButton).not.toBeDisabled();
await comboboxButton.click();
const searchInput = page.getByTestId("user-search-input");
await searchInput.fill("N-ZAP");
const option = page.getByTestId("user-search-item").first();
await option.click();
if (options.duration) {
await banForm.locator('input[name="duration"]').fill(options.duration);
}
if (options.reason) {
await banForm.locator('input[name="reason"]').fill(options.reason);
}
await waitForPOSTResponse(page, () =>
banForm.getByRole("button", { name: "Save" }).click(),
);
}
async function unbanUser(page: Page) {
const unbanForm = page
.locator("form")
.filter({ has: page.locator("h2", { hasText: /^Unban user$/ }) });
const comboboxButton = unbanForm.getByLabel("User");
await expect(comboboxButton).not.toBeDisabled();
await comboboxButton.click();
const searchInput = page.getByTestId("user-search-input");
await searchInput.fill("N-ZAP");
const option = page.getByTestId("user-search-item").first();
await option.click();
await waitForPOSTResponse(page, () =>
unbanForm.getByRole("button", { name: "Save" }).click(),
);
}
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 seed(page);
// 1. As admin, ban NZAP user
await impersonate(page, ADMIN_ID);
await navigate({ page, url: ADMIN_PAGE });
await banUser(page, { reason: "Test ban reason" });
// 2. As the banned user, try to access the site
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: "/" });
// Should be redirected to suspended page
const suspended = new SuspendedPage(page);
await expect(page).toHaveURL(SUSPENDED_PAGE);
await expect(page.getByText("Account suspended")).toBeVisible();
await expect(page.getByText("Reason: Test ban reason")).toBeVisible();
await expect(page.getByText("no end time set")).toBeVisible();
await expect(suspended.locators.heading).toBeVisible();
await expect(suspended.reason("Test ban reason")).toBeVisible();
await expect(suspended.locators.noEndTime).toBeVisible();
// 3. Verify user cannot navigate to other pages
await navigate({ page, url: "/builds" });
await expect(page).toHaveURL(SUSPENDED_PAGE);
await navigate({ page, url: "/calendar" });
await expect(page).toHaveURL(SUSPENDED_PAGE);
// 4. As admin, unban the user
await impersonate(page, ADMIN_ID);
await navigate({ page, url: ADMIN_PAGE });
await unbanUser(page);
await adminBan.goto();
await adminBan.unbanUser("N-ZAP");
// 5. As the unbanned user, verify they can access the site
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
// Should not be redirected to suspended page
await expect(page).not.toHaveURL(SUSPENDED_PAGE);
});
test("timed ban shows expiration date on suspended page", async ({
page,
}) => {
// await seed(page);
// 1. As admin, ban NZAP user with a future duration
await impersonate(page, ADMIN_ID);
await navigate({ page, url: ADMIN_PAGE });
// Set ban to expire tomorrow (well in the future)
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const year = tomorrow.getFullYear();
const month = String(tomorrow.getMonth() + 1).padStart(2, "0");
const day = String(tomorrow.getDate()).padStart(2, "0");
const formattedDate = `${year}-${month}-${day}T12:00`;
await banUser(page, { duration: formattedDate, reason: "Temporary ban" });
const adminBan = new AdminBanPage(page);
await adminBan.goto();
await adminBan.banUser("N-ZAP", {
until: startOfHour(setHours(addDays(new Date(), 1), 12)),
reason: "Temporary ban",
});
// 2. As the banned user, verify redirected to suspended page
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const suspended = new SuspendedPage(page);
await expect(page).toHaveURL(SUSPENDED_PAGE);
await expect(page.getByText("Account suspended")).toBeVisible();
await expect(page.getByText("Reason: Temporary ban")).toBeVisible();
// Should show expiration time, not "no end time set"
await expect(page.getByText("no end time set")).not.toBeVisible();
await expect(page.getByText("Ends:")).toBeVisible();
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();
// Note: The actual time-based expiration logic is tested in unit tests
// (see app/features/ban/core/banned.test.ts)
// the time based expiration logic itself is covered by app/features/ban/core/banned.test.ts
});
});

View File

@ -31,6 +31,7 @@ export async function loadFactories(parallelIndex: number) {
return {
backdate: (await import("~/db/seed/core/backdate")).backdate,
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"),

View File

@ -1,132 +1,83 @@
import { LFG_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
navigate,
submit,
test,
} from "./helpers/playwright";
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 seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
await new AnythingAdder(page).add("lfgPost");
await page.getByLabel("Text").fill("looking for a cool team");
const newPost = new NewLFGPostPage(page);
await newPost.form.fill("postText", "looking for a cool team");
await newPost.save();
await submit(page);
// got redirected
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(page.getByText("looking for a cool team")).toBeVisible();
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 seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
await impersonate(page, NZAP_TEST_ID);
// create post with Japanese and Korean
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await page.getByLabel("Text").fill("looking for Japanese/Korean team");
await newPost.form.fill("postText", "looking for Japanese/Korean team");
await newPost.checkLanguage("日本語");
await newPost.checkLanguage("한국어");
await newPost.save();
await page.getByLabel("日本語").check();
await page.getByLabel("한국어").check();
await submit(page);
// verify languages are displayed
await expect(page.getByText("JA / KO")).toBeVisible();
await expect(new LFGPage(page).languagePill("JA / KO")).toBeVisible();
});
test("edits post languages", async ({ page }) => {
// await seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
await impersonate(page, NZAP_TEST_ID);
// create post with Dansk
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await page.getByLabel("Text").fill("test post for language editing");
await newPost.form.fill("postText", "test post for language editing");
await newPost.checkLanguage("Dansk");
await newPost.save();
await page.getByLabel("Dansk").check();
const lfg = new LFGPage(page);
await expect(lfg.languagePill("DA")).toBeVisible();
await expect(lfg.post("test post for language editing")).toBeVisible();
await submit(page);
const editPost = await lfg.editFirstPost();
await editPost.uncheckLanguage("Dansk");
await editPost.checkLanguage("Español");
await editPost.save();
// wait for redirect to LFG page & verify the language is displayed
await expect(page.getByText("EN / DA", { exact: true })).toBeVisible();
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(
page.getByText("test post for language editing"),
).toBeVisible();
// remove Dansk and add Spanish
await page.getByRole("link", { name: "Edit" }).first().click();
await page.getByLabel("Dansk").uncheck();
await page.getByLabel("Español").check();
await submit(page);
// wait for redirect to LFG page
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(
page.getByText("test post for language editing"),
).toBeVisible();
// verify updated language is displayed
await expect(page.getByText("EN / ES", { exact: true })).toBeVisible();
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 seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
await impersonate(page, NZAP_TEST_ID);
// create post with Japanese
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-lfgPost").click();
const newPost = new NewLFGPostPage(page);
await newPost.goto();
await page.getByLabel("Text").fill("Japanese speaking team");
await newPost.form.fill("postText", "Japanese speaking team");
await newPost.checkLanguage("日本語");
await newPost.save();
await page.getByLabel("日本語").check();
const lfg = new LFGPage(page);
await expect(lfg.post("Japanese speaking team")).toBeVisible();
await submit(page);
await lfg.addLanguageFilter();
await lfg.selectFilterLanguage("ja");
// wait for redirect to LFG page
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(page.getByText("Japanese speaking team")).toBeVisible();
await expect(lfg.post("Japanese speaking team")).toBeVisible();
// filter by Japanese
await page.getByTestId("add-filter-button").click();
await page.getByText("Spoken language").click();
await page.getByLabel("Spoken language").selectOption("ja");
await lfg.selectFilterLanguage("es");
// verify Japanese post is visible
await expect(page.getByText("Japanese speaking team")).toBeVisible();
// change filter to Spanish
await page.getByLabel("Spoken language").selectOption("es");
// verify Japanese post is not visible
await expect(page.getByText("Japanese speaking team")).not.toBeVisible();
await expect(lfg.post("Japanese speaking team")).not.toBeVisible();
});
});

View File

@ -1,177 +1,103 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, navigate, test } from "./helpers/playwright";
import { MobileNav } from "./pages/layout/mobile-nav";
import { SideNav } from "./pages/layout/side-nav";
import { TopNavMenus } from "./pages/layout/top-nav-menus";
test.describe("Navigation", () => {
test("desktop navigation", async ({ page }) => {
// await seed(page);
await impersonate(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
// SideNav visible with section headings
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible();
const sideNav = new SideNav(page);
await expect(sideNav.sectionHeading("Events")).toBeVisible();
await expect(sideNav.sectionHeading("Friends")).toBeVisible();
await expect(sideNav.sectionHeading("Streams")).toBeVisible();
await expect(sideNav.locators.viewAllLinks.first()).toBeVisible();
// View all links present
const viewAllLinks = page.getByRole("link", { name: /View all/ });
await expect(viewAllLinks.first()).toBeVisible();
await sideNav.toggleCollapse();
await expect(sideNav.sectionHeading("Events")).not.toBeVisible();
// SideNav collapse/uncollapse
const collapseButton = page.getByTestId("sidenav-collapse-button");
await collapseButton.click();
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
await sideNav.toggleCollapse();
await expect(sideNav.sectionHeading("Events")).toBeVisible();
await collapseButton.click();
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await expect(topNav.link("Scrims")).toBeVisible();
// TopNavMenus — Play
await page.getByRole("button", { name: "Play" }).click();
const playMenu = page.locator("[class*='menuContent']");
await expect(playMenu.getByRole("link", { name: "SendouQ" })).toBeVisible();
await expect(playMenu.getByRole("link", { name: "Scrims" })).toBeVisible();
await playMenu.getByRole("link", { name: "SendouQ" }).click();
await expect(
playMenu.getByRole("link", { name: "SendouQ" }),
).not.toBeVisible();
await topNav.link("SendouQ").click();
await expect(topNav.link("SendouQ")).not.toBeVisible();
await navigate({ page, url: "/" });
// TopNavMenus — Tools
await page.getByRole("button", { name: "Tools" }).click();
const toolsMenu = page.locator("[class*='menuContent']");
await expect(
toolsMenu.getByRole("link", { name: "Analyzer" }),
).toBeVisible();
await page.keyboard.press("Escape");
await topNav.open("Tools");
await expect(topNav.link("Analyzer")).toBeVisible();
await topNav.close();
// TopNavMenus — Community
await page.getByRole("button", { name: "Community" }).click();
const communityMenu = page.locator("[class*='menuContent']");
await expect(
communityMenu.getByRole("link", { name: "Builds" }),
).toBeVisible();
await page.keyboard.press("Escape");
await topNav.open("Community");
await expect(topNav.link("Builds")).toBeVisible();
await topNav.close();
// SideNav footer — user info
await expect(page.getByTestId("notifications-button")).toBeVisible();
await expect(sideNav.locators.notificationsButton).toBeVisible();
});
test("mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
// await seed(page);
await impersonate(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
// Tab bar visible
const menuTab = page.getByRole("button", { name: "Menu" });
const friendsTab = page.getByRole("button", { name: "Friends" });
const calendarTab = page.getByRole("button", { name: "Events" });
const chatTab = page.getByRole("button", { name: "Chat" });
const youTab = page.getByRole("button", { name: "You" });
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 expect(menuTab).toBeVisible();
await expect(friendsTab).toBeVisible();
await expect(calendarTab).toBeVisible();
await expect(chatTab).toBeVisible();
await expect(youTab).toBeVisible();
await mobileNav.openPanel("menu");
await expect(mobileNav.menuLink("SendouQ")).toBeVisible();
await expect(mobileNav.menuLink("Analyzer")).toBeVisible();
await expect(mobileNav.menuLink("Builds")).toBeVisible();
await expect(mobileNav.locators.streamsHeading).toBeVisible();
// Menu panel — open and verify contents
await menuTab.click();
const mobileMenu = page.getByLabel("Menu", { exact: true });
await expect(
mobileMenu.getByRole("link", { name: "SendouQ" }),
).toBeVisible();
await expect(
mobileMenu.getByRole("link", { name: "Analyzer" }),
).toBeVisible();
await expect(
mobileMenu.getByRole("link", { name: "Builds" }),
).toBeVisible();
await expect(
page.locator("h3").filter({ hasText: "Streams" }),
).toBeVisible();
await mobileNav.switchPanel("friends");
await expect(mobileNav.menuLink("SendouQ")).not.toBeVisible();
await expect(mobileNav.locators.viewAllLink).toBeVisible();
// Switch from menu to friends panel via ghost tab
// Ghost tabs are invisible overlays; use dispatchEvent to trigger them
// nth(1) = "friends" ghost tab (0=menu, 1=friends, 2=tourneys, 3=chat, 4=you)
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(1)
.dispatchEvent("click");
await expect(
mobileMenu.getByRole("link", { name: "SendouQ" }),
).not.toBeVisible();
const friendsViewAll = page.getByRole("link", {
name: "View all",
exact: true,
});
await expect(friendsViewAll).toBeVisible();
await mobileNav.switchPanel("you");
await expect(mobileNav.locators.youPanelUsername).toBeVisible();
// Switch to You panel via ghost tab (nth(4) = "you")
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(4)
.dispatchEvent("click");
// You panel shows user info (username link)
await expect(page.locator("[class*='youPanelUsername']")).toBeVisible();
await mobileNav.switchPanel("tourneys");
await expect(mobileNav.locators.viewAllLink).toBeVisible();
// Switch to calendar panel via ghost tab (nth(2) = "tourneys")
await page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.nth(2)
.dispatchEvent("click");
const tourneysViewAll = page.getByRole("link", {
name: "View all",
exact: true,
});
await expect(tourneysViewAll).toBeVisible();
// Close panel via X button
await page.locator("button:has(svg.lucide-x)").first().click();
await expect(tourneysViewAll).not.toBeVisible();
await mobileNav.closePanel();
await expect(mobileNav.locators.viewAllLink).not.toBeVisible();
});
test("tablet navigation", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
// await seed(page);
await impersonate(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
// SideNav not visible as permanent sidebar
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "Friends" }),
).not.toBeVisible();
const sideNav = new SideNav(page);
await expect(sideNav.sectionHeading("Events")).not.toBeVisible();
await expect(sideNav.sectionHeading("Friends")).not.toBeVisible();
// Hamburger opens SideNav modal
const modalTrigger = page.getByTestId("sidenav-modal-trigger");
await modalTrigger.click();
await sideNav.openModal();
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible();
await expect(
page.getByRole("link", { name: /View all/ }).first(),
).toBeVisible();
await expect(sideNav.sectionHeading("Events")).toBeVisible();
await expect(sideNav.sectionHeading("Friends")).toBeVisible();
await expect(sideNav.sectionHeading("Streams")).toBeVisible();
await expect(sideNav.locators.viewAllLinks.first()).toBeVisible();
// Close modal by pressing Escape
await page.keyboard.press("Escape");
await expect(
page.getByRole("heading", { name: "Events" }),
).not.toBeVisible();
await sideNav.closeModal();
await expect(sideNav.sectionHeading("Events")).not.toBeVisible();
// TopNavMenus still work
await page.getByRole("button", { name: "Play" }).click();
const tabletPlayMenu = page.locator("[class*='menuContent']");
await expect(
tabletPlayMenu.getByRole("link", { name: "SendouQ" }),
).toBeVisible();
await page.keyboard.press("Escape");
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await topNav.close();
// MobileNav hidden
await expect(page.getByRole("button", { name: "Menu" })).not.toBeVisible();
await expect(new MobileNav(page).tab("menu")).not.toBeVisible();
});
});

52
e2e/pages/api/api-page.ts Normal file
View File

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

View File

@ -0,0 +1,74 @@
import type { Locator, Page } from "@playwright/test";
import { format } from "date-fns";
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: { until?: Date; reason?: string } = {},
) {
const form = this.locators.banForm;
await selectUser({
page: this.page,
userName,
labelName: "User",
within: form,
});
if (options.until) {
await form
.locator('input[name="duration"]')
.fill(format(options.until, "yyyy-MM-dd'T'HH:mm"));
}
if (options.reason) {
await form.locator('input[name="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 save(form: Locator) {
await waitForPOSTResponse(this.page, () =>
form.getByRole("button", { name: "Save" }).click(),
);
}
}

View File

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

View File

@ -0,0 +1,56 @@
import type { Page } from "@playwright/test";
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. */
export class MobileNav {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
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']"),
};
}
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 });
}
}

View File

@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
type Section = "Events" | "Friends" | "Streams";
/** The sidebar of the site layout, a modal behind a hamburger on narrower viewports. */
export class SideNav {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
collapseButton: page.getByTestId("sidenav-collapse-button"),
modalTrigger: page.getByTestId("sidenav-modal-trigger"),
notificationsButton: page.getByTestId("notifications-button"),
viewAllLinks: page.getByRole("link", { name: /View all/ }),
};
}
sectionHeading(section: Section) {
return this.page.getByRole("heading", { name: section });
}
async toggleCollapse() {
await this.locators.collapseButton.click();
}
async openModal() {
await this.locators.modalTrigger.click();
}
async closeModal() {
await this.page.keyboard.press("Escape");
}
}

View File

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

45
e2e/pages/lfg/lfg-page.ts Normal file
View File

@ -0,0 +1,45 @@
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 { 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"),
};
}
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 });
}
async editFirstPost() {
await this.page.getByRole("link", { name: "Edit" }).first().click();
return new NewLFGPostPage(this.page);
}
async addLanguageFilter() {
await this.locators.addFilterButton.click();
await this.page.getByRole("menuitem", { name: "Spoken language" }).click();
}
async selectFilterLanguage(code: UnifiedLanguageCode) {
await this.locators.languageFilterSelect.selectOption(code);
}
}

View File

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

View File

@ -0,0 +1,53 @@
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.page.getByLabel("With").selectOption("PICKUP");
for (const [index, userName] of userNames.entries()) {
await selectUser({
page: this.page,
labelName: `User ${index + 2}`,
userName,
});
}
}
/** Limits who sees the post to one of the author's associations. */
async selectVisibility(associationName: string) {
await this.page
.getByLabel("Visibility")
.selectOption({ label: associationName });
}
async selectTournamentMaps(tournamentName: string) {
await this.form.select("maps", "TOURNAMENT");
await selectTournament({ page: this.page, query: tournamentName });
}
async save() {
await submit(this.page);
}
}

View File

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

View File

@ -1,7 +1,23 @@
import type { Page } from "@playwright/test";
import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas";
import { scrimsPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
import {
modalClickConfirmButton,
navigate,
selectUser,
submit,
} 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;
@ -11,6 +27,17 @@ export class ScrimsPage {
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"),
};
}
@ -18,8 +45,79 @@ export class ScrimsPage {
await navigate({ page: this.page, url: scrimsPage() });
}
post(text: string) {
return this.page.getByText(text);
}
async openTab(tab: Tab) {
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
}
async openAssociations() {
await this.locators.associationsLink.click();
return new AssociationsPage(this.page);
}
async requestFirst() {
await this.locators.requestButtons.first().click();
return new ScrimRequestModal(this.page);
}
/** Posts of the available tab whose request is pending are hidden until asked for. */
async showPendingRequests() {
await this.locators.togglePendingRequestsButton.first().click();
}
async cancelPendingRequest() {
await this.locators.viewRequestButtons.first().click();
await this.page.getByRole("button", { name: "Cancel" }).click();
}
async acceptFirstRequest() {
await this.locators.acceptRequestButtons.first().click();
await modalClickConfirmButton(this.page);
}
async deleteFirstPost() {
await this.locators.deleteButtons.first().click();
await modalClickConfirmButton(this.page);
}
async openFirstBookedScrim() {
await this.locators.contactLinks.first().click();
return new ScrimPage(this.page);
}
}
/** The dialog for requesting a scrim of somebody else's post. */
class ScrimRequestModal {
private readonly page: Page;
readonly form;
constructor(page: Page) {
this.page = page;
this.form = createFormHelpers(page, scrimRequestFormSchema);
}
/** The requester themselves is the first of the pick-up, the rest are chosen here. */
async selectPickupUsers(userNames: string[]) {
for (const [index, userName] of userNames.entries()) {
await selectUser({
page: this.page,
labelName: `User ${index + 2}`,
userName,
});
}
}
/** Only posts with a flexible start time offer times to pick from. */
async selectStartTime(nth: number) {
await this.page
.getByLabel(this.form.getLabel("at"))
.selectOption({ index: nth });
}
async send() {
await submit(this.page);
}
}

View File

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

View File

@ -0,0 +1,22 @@
import type { Page } from "@playwright/test";
import { tournamentTeamPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
export class TournamentTeamPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
memberNames: page.getByTestId("team-member-name"),
};
}
async goto(tournamentId: number, tournamentTeamId: number) {
await navigate({
page: this.page,
url: tournamentTeamPage({ tournamentId, tournamentTeamId }),
});
}
}

View File

@ -1,418 +1,388 @@
import type { Page } from "@playwright/test";
import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import {
scrimsNewFormSchema,
submitMapListFormSchema,
} from "~/features/scrims/scrims-schemas";
import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { toDBBoolean } from "~/utils/sql";
import type { Factories } from "./helpers/factories";
import {
expect,
impersonate,
isNotVisible,
navigate,
selectUser,
submit,
test,
waitForPOSTResponse,
} from "./helpers/playwright";
import { createFormHelpers } from "./helpers/playwright-form";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { NewScrimPostPage } from "./pages/scrims/new-scrim-post-page";
import { ScrimPage } from "./pages/scrims/scrim-page";
import { ScrimsPage } from "./pages/scrims/scrims-page";
const TEST_POOL_SERIALIZED = "sz:3a14000;tc:2c98000";
const TOURNAMENT_NAME = "Swim or Sink";
const ASSOCIATION_NAME = "Inkling Alliance";
const PICKUP_NAMES = ["Pickup One", "Pickup Two", "Pickup Three"];
const GROUP_SIZE = 4;
const TOURNAMENT_MAP_POOL: Array<{ mode: ModeShort; stageId: StageId }> = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 2 },
{ mode: "RM", stageId: 3 },
{ mode: "CB", stageId: 4 },
];
test.describe("Scrims", () => {
test("creates a new scrim & deletes it", async ({ page }) => {
// await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: "/",
test("creates a new scrim & deletes it", async ({ page, factories }) => {
await createNamedUsers(factories, PICKUP_NAMES);
await factories.AssociationFactory.create({
userId: NZAP_TEST_ID,
name: ASSOCIATION_NAME,
});
await page.getByTestId("anything-adder-menu-button").first().click();
await page.getByTestId("menu-item-scrimPost").click();
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const form = createFormHelpers(page, scrimsNewFormSchema);
await new AnythingAdder(page).add("scrimPost");
await page.getByLabel("With").selectOption("PICKUP");
await selectUser({
labelName: "User 2",
page,
userName: "N-ZAP",
});
await selectUser({
labelName: "User 3",
page,
userName: "ab",
});
await selectUser({
labelName: "User 4",
page,
userName: "de",
});
await page.getByLabel("Visibility").selectOption("2");
const newPost = new NewScrimPostPage(page);
await newPost.selectPickupUsers(PICKUP_NAMES);
await newPost.selectVisibility(ASSOCIATION_NAME);
await newPost.form.fill("postText", "Test scrim");
await newPost.save();
await form.fill("postText", "Test scrim");
const scrims = new ScrimsPage(page);
await expect(scrims.locators.limitedVisibilityPopover).toBeVisible();
await submit(page);
await scrims.deleteFirstPost();
await expect(page.getByTestId("limited-visibility-popover")).toBeVisible();
await page.getByRole("button", { name: "Delete" }).first().click();
await submit(page, "confirm-button");
await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1);
await expect(scrims.locators.deleteButtons).toHaveCount(0);
});
test("requests an existing scrim post & cancels the request", async ({
page,
factories,
}) => {
// await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
for (let postNth = 0; postNth < 2; postNth++) {
await factories.ScrimPostFactory.create({
users: await createGroup(factories),
});
}
await createTeamFor(factories, NZAP_TEST_ID);
const requestScrimButtonLocator = page.getByTestId("request-scrim-button");
await impersonate(page, NZAP_TEST_ID);
await page.getByTestId("available-scrims-tab").click();
const scrims = new ScrimsPage(page);
await scrims.goto();
await scrims.openTab("available");
await expect(requestScrimButtonLocator.first()).toBeVisible();
await expect(scrims.locators.requestButtons).toHaveCount(2);
const initialCount = await requestScrimButtonLocator.count();
const request = await scrims.requestFirst();
await request.send();
await requestScrimButtonLocator.first().click();
await expect(scrims.locators.requestButtons).toHaveCount(1);
await submit(page);
await scrims.showPendingRequests();
await scrims.cancelPendingRequest();
await expect(requestScrimButtonLocator).toHaveCount(initialCount - 1);
const togglePendingRequestsButton = page.getByTestId(
"toggle-pending-requests-button",
);
await togglePendingRequestsButton.first().click();
await page.getByTestId("view-request-button").first().click();
const cancelButton = page.getByRole("button", {
name: "Cancel",
});
await cancelButton.click();
await expect(requestScrimButtonLocator).toHaveCount(initialCount);
await expect(scrims.locators.requestButtons).toHaveCount(2);
});
test("accepts a request", async ({ page }) => {
// await seed(page);
test("accepts a request", async ({ page, factories }) => {
await createPostWithRequest(factories, { ownerUserId: ADMIN_ID });
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
await page.getByTestId("confirm-modal-trigger-button").first().click();
await submit(page, "confirm-button");
const scrims = new ScrimsPage(page);
await scrims.goto();
await scrims.acceptFirstRequest();
await page.getByTestId("booked-scrims-tab").click();
await scrims.openTab("booked");
await expect(scrims.locators.contactLinks).toHaveCount(1);
const contactButtonLocator = page.getByRole("link", { name: "Contact" });
const scrim = await scrims.openFirstBookedScrim();
await expect(contactButtonLocator).toHaveCount(2);
await page.getByRole("link", { name: "Contact" }).first().click();
await expect(page.getByText("Scheduled scrim")).toBeVisible();
await expect(scrim.locators.subtitle).toBeVisible();
});
test("auto-cancels overlapping pending scrims when a scrim is booked", async ({
page,
factories,
}) => {
// await seed(page, "NO_SCRIMS");
await impersonate(page);
await createTeamFor(factories, ADMIN_ID);
await createTeamFor(factories, NZAP_TEST_ID);
const bookedAt = new Date();
bookedAt.setDate(bookedAt.getDate() + 1);
bookedAt.setHours(18, 0, 0, 0);
const bookedAt = startOfHour(setHours(addDays(new Date(), 1), 18));
// within ±1h of the booked time
const overlappingAt = setMinutes(bookedAt, 30);
// outside the ±1h window
const farAwayAt = setHours(bookedAt, 22);
const overlappingAt = new Date(bookedAt);
overlappingAt.setMinutes(30); // within ±1h of the booked time
await impersonate(page, ADMIN_ID);
const farAwayAt = new Date(bookedAt);
farAwayAt.setHours(22, 0, 0, 0); // outside the ±1h window
const newPost = new NewScrimPostPage(page);
for (const [at, text] of [
[bookedAt, "Booked post"],
[overlappingAt, "Overlapping post"],
[farAwayAt, "Far away post"],
] as const) {
await newPost.goto();
await newPost.form.setDateTime("at", at);
await newPost.form.fill("postText", text);
await newPost.save();
}
await createScrimPost(page, bookedAt, "Booked post");
await createScrimPost(page, overlappingAt, "Overlapping post");
await createScrimPost(page, farAwayAt, "Far away post");
// NZAP requests the earliest (soon-to-be-booked) post
// N-ZAP requests the earliest (soon-to-be-booked) post
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: scrimsPage() });
await page.getByTestId("available-scrims-tab").click();
await page.getByTestId("request-scrim-button").first().click();
await selectUser({ labelName: "User 2", page, userName: "5" });
await selectUser({ labelName: "User 3", page, userName: "6" });
await selectUser({ labelName: "User 4", page, userName: "7" });
await submit(page);
// Author accepts the request, booking the scrim
await impersonate(page);
await navigate({ page, url: scrimsPage() });
await page.getByTestId("confirm-modal-trigger-button").first().click();
await submit(page, "confirm-button");
const scrims = new ScrimsPage(page);
await scrims.goto();
await scrims.openTab("available");
// The overlapping pending post is auto-removed, the far away one survives
await page.getByRole("tab", { name: /Owned/ }).click();
await expect(page.getByText("Far away post")).toBeVisible();
await expect(page.getByText("Overlapping post")).toHaveCount(0);
const request = await scrims.requestFirst();
await request.send();
// the author accepts the request, booking the scrim
await impersonate(page, ADMIN_ID);
await scrims.goto();
await scrims.acceptFirstRequest();
await scrims.openTab("owned");
await expect(scrims.post("Far away post")).toBeVisible();
await isNotVisible(scrims.post("Overlapping post"));
});
test("cancels a scrim and shows canceled status", async ({ page }) => {
// await seed(page);
test("cancels a scrim and shows canceled status", async ({
page,
factories,
}) => {
await createPostWithRequest(factories, {
ownerUserId: ADMIN_ID,
at: addHours(new Date(), 4),
});
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
// Accept the first available scrim request to make it possible to access the scrim details page
await page.getByTestId("confirm-modal-trigger-button").first().click();
await submit(page, "confirm-button");
const scrims = new ScrimsPage(page);
await scrims.goto();
await page.getByTestId("booked-scrims-tab").click();
// accepting is what makes the scrim's own page accessible
await scrims.acceptFirstRequest();
await scrims.openTab("booked");
await page.getByRole("link", { name: "Contact" }).first().click();
const scrim = await scrims.openFirstBookedScrim();
await scrim.cancel("Oops something came up");
// Cancel the scrim
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByLabel("Reason").fill("Oops something came up");
await submit(page, "cancel-scrim-submit");
await scrims.goto();
// Go back to the scrims page and check if the scrim is marked as canceled
await navigate({
page,
url: scrimsPage(),
});
await expect(page.getByText("Canceled")).toBeVisible();
await expect(scrims.locators.canceledLabel).toBeVisible();
});
test("creates scrim with start time and tournament maps, accepts with time and message", async ({
page,
factories,
}) => {
// await seed(page, "NO_SCRIMS");
await impersonate(page);
await navigate({
page,
url: newScrimPostPage(),
});
await createTeamFor(factories, ADMIN_ID);
await createNamedUsers(factories, PICKUP_NAMES);
await createTournament(factories);
const form = createFormHelpers(page, scrimsNewFormSchema);
const tomorrowDate = new Date();
tomorrowDate.setDate(tomorrowDate.getDate() + 1);
tomorrowDate.setHours(18, 0, 0, 0);
await form.setDateTime("at", tomorrowDate);
await form.select("rangeEnd", "+2hours");
await form.select("maps", "TOURNAMENT");
const tournamentSearchInput = page.getByTestId("tournament-search-input");
const tournamentSearchItem = page.getByTestId("tournament-search-item");
await page.getByRole("button", { name: /Tournament search/i }).click();
await tournamentSearchInput.fill("Swim or Sink");
await expect(tournamentSearchItem.first()).toBeVisible();
await tournamentSearchItem.first().click();
await submit(page);
// Log in as NZAP user and request the scrim
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: scrimsPage(),
});
await page.getByTestId("available-scrims-tab").click();
// Find and click the request button for the scrim we just created
await page.getByTestId("request-scrim-button").first().click();
await selectUser({
labelName: "User 2",
page,
userName: "5",
});
await selectUser({
labelName: "User 3",
page,
userName: "6",
});
await selectUser({
labelName: "User 4",
page,
userName: "7",
});
await page.getByLabel("Start time").selectOption({ index: 1 });
await page.getByLabel("Message").fill("Ready to scrim! Let's do this.");
await submit(page);
// Log back in as the author (admin) and verify the scrim and request details
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
await expect(page.getByText("+2h")).toBeVisible();
await expect(page.getByTestId("tournament-popover-trigger")).toBeVisible();
const newPost = new NewScrimPostPage(page);
await newPost.goto();
await newPost.form.setDateTime(
"at",
startOfHour(setHours(addDays(new Date(), 1), 18)),
);
await newPost.form.select("rangeEnd", "+2hours");
await newPost.selectTournamentMaps(TOURNAMENT_NAME);
await newPost.save();
await expect(
page.getByText("Ready to scrim! Let's do this."),
).toBeVisible();
await impersonate(page, NZAP_TEST_ID);
await page.getByTestId("confirm-modal-trigger-button").click();
await submit(page, "confirm-button");
const scrims = new ScrimsPage(page);
await scrims.goto();
await scrims.openTab("available");
await page.getByTestId("booked-scrims-tab").click();
await page.getByRole("link", { name: "Contact" }).click();
const request = await scrims.requestFirst();
await request.selectPickupUsers(PICKUP_NAMES);
await request.selectStartTime(1);
await request.form.fill("message", "Ready to scrim! Let's do this.");
await request.send();
await page.getByRole("tab", { name: "Action" }).click();
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
// back as the author, who sees the post and the request details
await impersonate(page, ADMIN_ID);
await scrims.goto();
await expect(scrims.post("+2h")).toBeVisible();
await expect(scrims.locators.tournamentPopover).toBeVisible();
await expect(scrims.post("Ready to scrim! Let's do this.")).toBeVisible();
await scrims.acceptFirstRequest();
await scrims.openTab("booked");
const scrim = await scrims.openFirstBookedScrim();
await scrim.openTab("action");
await expect(scrim.locators.mapListForm).toBeVisible();
});
test("map-by-map: lists, report, undo, replay, change list, stats", async ({
page,
factories,
}) => {
// await seed(page);
const scrimUrl = scrimPage(1);
const mapListForm = createFormHelpers(page, submitMapListFormSchema, {
submitTestId: "submit-map-list-button",
const tournament = await createTournament(factories);
const post = await createPostWithRequest(factories, {
ownerUserId: ADMIN_ID,
requesterUserId: NZAP_TEST_ID,
mapsTournamentId: tournament.id,
isAccepted: true,
});
// ADMIN opens the Action tab — the map list form is shown immediately
// the admin opens the Action tab — the map list form is shown immediately
await impersonate(page, ADMIN_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
// ADMIN submits a map list — the post's tournament (Swim or Sink) is the
// default source for the scrim author's team, so they can submit without
// running the tournament search. A first map is generated immediately
// so the page transitions to the report UI with the map-list manager
// collapsed.
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("report-score-button")).toBeVisible();
await page.getByRole("button", { name: /Manage map lists/i }).click();
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText(
"Swim or Sink",
);
const scrim = new ScrimPage(page);
await scrim.goto(post.id);
await scrim.openTab("action");
// NZAP submits a pool-URL-based map list. They have no list yet so the
await expect(scrim.locators.mapListForm).toBeVisible();
// the post's tournament is the default source for the author's side, so they
// can submit without running the tournament search. A first map is generated
// immediately, so the page moves on to the report UI with the map-list
// manager collapsed.
await scrim.submitMapList();
await expect(scrim.locators.reportScoreButton).toBeVisible();
await scrim.openMapListManager();
await expect(scrim.mapListRow("ALPHA")).toContainText(TOURNAMENT_NAME);
// N-ZAP submits a pool-URL-based map list. They have no list yet, so the
// map-list manager is already expanded on mount.
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await page.getByLabel("Pool URL").click();
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("report-score-button")).toBeVisible();
await expect(page.getByTestId("map-list-row-BRAVO")).toContainText("Pool");
await scrim.goto(post.id);
await scrim.openTab("action");
await scrim.submitPoolMapList(TEST_POOL_SERIALIZED);
// Map 1: ALPHA wins → next map auto-generated
await reportScrimMapWinner(page, "ALPHA");
await expect(page.getByTestId("report-score-button")).toBeVisible();
await expect(scrim.locators.reportScoreButton).toBeVisible();
await expect(scrim.mapListRow("BRAVO")).toContainText("Pool");
// Map 2: BRAVO wins → next map auto-generated
await reportScrimMapWinner(page, "BRAVO");
await expect(page.getByTestId("report-score-button")).toBeVisible();
// map 1: ALPHA wins → next map auto-generated
await scrim.reportMapWinner("ALPHA");
await expect(scrim.locators.reportScoreButton).toBeVisible();
// Map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4)
await reportScrimMapWinner(page, "ALPHA");
await expect(page.getByTestId("undo-map-button")).toBeVisible();
await submit(page, "undo-map-button");
await expect(page.getByTestId("report-score-button")).toBeVisible();
// map 2: BRAVO wins → next map auto-generated
await scrim.reportMapWinner("BRAVO");
await expect(scrim.locators.reportScoreButton).toBeVisible();
// Re-report map 3 as BRAVO wins → next map auto-generated
await reportScrimMapWinner(page, "BRAVO");
// map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4)
await scrim.reportMapWinner("ALPHA");
await expect(scrim.locators.undoMapButton).toBeVisible();
await scrim.undoMap();
await expect(scrim.locators.reportScoreButton).toBeVisible();
// Replay last map: replaces the current generated map with a copy of
// the previous reported one, then report ALPHA wins
await expect(page.getByTestId("replay-map-button")).toBeVisible();
await submit(page, "replay-map-button");
await reportScrimMapWinner(page, "ALPHA");
// re-report map 3 as BRAVO wins → next map auto-generated
await scrim.reportMapWinner("BRAVO");
// Switch back to ADMIN to change their list
// replaying replaces the generated map with a copy of the previous one
await expect(scrim.locators.replayMapButton).toBeVisible();
await scrim.replayMap();
await scrim.reportMapWinner("ALPHA");
// back as the admin to change their list
await impersonate(page, ADMIN_ID);
await navigate({ page, url: scrimUrl });
await page.getByRole("tab", { name: "Action" }).click();
await page.getByRole("button", { name: /Manage map lists/i }).click();
await scrim.goto(post.id);
await scrim.openTab("action");
await scrim.openMapListManager();
// Remove ALPHA's tournament list (trash icon opens a confirm dialog)
await page
.getByTestId("map-list-row-ALPHA")
.getByLabel(/Remove list/i)
.click();
await waitForPOSTResponse(page, () => submit(page, "confirm-button"));
await expect(page.getByTestId("scrim-map-list-form")).toBeVisible();
await scrim.removeOwnMapList("ALPHA");
// Re-submit ALPHA's list, this time as a pool URL
await page.getByLabel("Pool URL").click();
await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED);
await waitForPOSTResponse(page, () => mapListForm.submit());
await expect(page.getByTestId("map-list-row-ALPHA")).toContainText("Pool");
await expect(scrim.locators.mapListForm).toBeVisible();
// Verify stats tab reflects the played maps
await page.getByRole("tab", { name: "Stats" }).click();
await expect(page.getByTestId("scrim-stats-root")).toBeVisible();
await scrim.submitPoolMapList(TEST_POOL_SERIALIZED);
// Four reported maps total (Alpha 2 / Bravo 2 from ADMIN's POV).
// Switch to "Mode" view so each row groups by mode, and disable the
// pool restriction so maps outside ADMIN's resubmitted pool still count.
// Sum of wins+losses across rows should equal 4.
await page
.getByTestId("scrim-stats-root")
.getByText("Mode", { exact: true })
.click();
await page.getByRole("switch").click({ force: true });
await expect(scrim.mapListRow("ALPHA")).toContainText("Pool");
const statsRoot = page.getByTestId("scrim-stats-root");
const winCells = await statsRoot
.locator("tbody tr td:nth-child(2)")
.allInnerTexts();
const lossCells = await statsRoot
.locator("tbody tr td:nth-child(3)")
.allInnerTexts();
const total =
winCells.reduce((acc, v) => acc + Number(v), 0) +
lossCells.reduce((acc, v) => acc + Number(v), 0);
expect(total).toBe(4);
await scrim.openTab("stats");
await expect(scrim.locators.statsRoot).toBeVisible();
// four reported maps in total (2 wins & 2 losses from the admin's point of view)
await scrim.showModeStatsOfAllMaps();
expect(await scrim.reportedMapCount()).toBe(4);
});
});
async function createScrimPost(page: Page, at: Date, text: string) {
await navigate({ page, url: newScrimPostPage() });
const form = createFormHelpers(page, scrimsNewFormSchema);
await form.setDateTime("at", at);
await form.fill("postText", text);
await submit(page);
/** A tournament with a map pool, which a scrim can play its maps off. */
function createTournament(factories: Factories) {
return factories.TournamentFactory.create({
authorId: ADMIN_ID,
name: TOURNAMENT_NAME,
startTimes: [dateToDatabaseTimestamp(addDays(new Date(), 1))],
mapPoolMaps: TOURNAMENT_MAP_POOL,
});
}
async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2";
await expect(
page.locator('[data-testid^="winner-radio-"][data-selected="true"]'),
).toHaveCount(0);
await page.getByTestId(testId).click();
await submit(page, "report-score-button");
/** Users a user search finds by the name given to them. */
function createNamedUsers(factories: Factories, names: string[]) {
return factories.UserFactory.createMany(names.length, (index) => ({
discordName: names[index],
}));
}
async function createTeamFor(factories: Factories, userId: number) {
const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1);
return factories.TeamFactory.create({
memberUserIds: [userId, ...teammates.map((user) => user.id)],
});
}
/** A pick-up sized group of users, `userId` its owner if one is given. */
async function createGroup(factories: Factories, userId?: number) {
const others = await factories.UserFactory.createMany(
userId ? GROUP_SIZE - 1 : GROUP_SIZE,
);
const userIds = userId
? [userId, ...others.map((user) => user.id)]
: others.map((user) => user.id);
return userIds.map((id, index) => ({
userId: id,
isOwner: toDBBoolean(index === 0),
}));
}
/** A scrim post with one request made to it, booked when `isAccepted`. */
async function createPostWithRequest(
factories: Factories,
{
ownerUserId,
requesterUserId,
at,
mapsTournamentId,
isAccepted,
}: {
ownerUserId: number;
requesterUserId?: number;
at?: Date;
mapsTournamentId?: number;
isAccepted?: boolean;
},
) {
const users = await createGroup(factories, ownerUserId);
const requesters = await createGroup(factories, requesterUserId);
return factories.ScrimPostFactory.create(
{
users,
startsAt: dateToDatabaseTimestamp(at ?? new Date()),
isScheduledForFuture: Boolean(at),
mapsTournamentId: mapsTournamentId ?? null,
},
{ requests: [{ users: requesters, isAccepted }] },
);
}

View File

@ -1,86 +1,53 @@
import type { Locator, Page } from "@playwright/test";
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
import { expect, navigate, test } from "./helpers/playwright";
import { expect, test } from "./helpers/playwright";
import { TierListMakerPage } from "./pages/tier-list-maker/tier-list-maker-page";
test.describe("Tier List Maker", () => {
test("toggles work, items can be dragged, and state persists after reload", async ({
page,
}) => {
await navigate({ page, url: TIER_LIST_MAKER_URL });
const tierList = new TierListMakerPage(page);
await tierList.goto();
await page.getByText("Drag & drop").click();
await tierList.setPlacementMode("drag");
const emptyTiers = page.getByText("Drop items here");
await expect(tierList.locators.emptyTiersDragMode).toHaveCount(5);
await expect(emptyTiers).toHaveCount(5);
await tierList.toggle("noDuplicates");
await tierList.toggle("showTierHeaders");
await tierList.toggle("hideAltKits");
await tierList.toggle("hideAltSkins");
// Test that toggles are clickable (just verify they work)
await page.getByText("No duplicates").click();
await page.getByText("Show tier headers").click();
await page.getByText("Hide alt kits").click();
await page.getByText("Hide alt skins").click();
await tierList.dragFirstItemToLastEmptyTier("main-weapon");
await tierList.dragFirstItemToLastEmptyTier("main-weapon");
// Drag first weapon to first tier
const firstWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
await dragItemToTier(page, firstWeapon);
await tierList.openTab("stage");
await expect(tierList.poolItems("stage").first()).toBeVisible();
await tierList.dragFirstItemToLastEmptyTier("stage");
// Drag second weapon to second tier
const secondWeapon = page.locator('[data-item-id^="main-weapon:"]').first();
await dragItemToTier(page, secondWeapon);
await tierList.reload();
// Switch to Stages tab and drag a stage
await page.getByText("Stages").click();
// placement mode is not persisted, unlike the tiers themselves
await tierList.setPlacementMode("drag");
const firstStage = page.locator('[data-item-id^="stage:"]').first();
await expect(firstStage).toBeVisible();
await dragItemToTier(page, firstStage);
// Wait for state to settle
await page.waitForTimeout(1000);
// Reload the page
await page.reload();
// Placement mode is not persisted, so re-enter drag & drop mode
await page.getByText("Drag & drop").click();
// Verify items persisted in the tier list (not in the pool)
// Should have fewer than 5 "Drop items here" (meaning at least 1 tier is filled)
await expect(emptyTiers).toHaveCount(3);
// each of the three dragged items landed in a tier of its own
await expect(tierList.locators.emptyTiersDragMode).toHaveCount(2);
});
test("click to place mode adds items to the selected tier", async ({
page,
}) => {
await navigate({ page, url: TIER_LIST_MAKER_URL });
const tierList = new TierListMakerPage(page);
await tierList.goto();
// Click to place is the default mode
const emptyTiers = page.getByText("Click items to add here");
await expect(emptyTiers).toHaveCount(5);
// click to place is the default mode
await expect(tierList.locators.emptyTiersClickMode).toHaveCount(5);
// The first tier is selected by default, clicking a weapon places it there
await page.locator('button[data-item-id^="main-weapon:"]').first().click();
await expect(emptyTiers).toHaveCount(4);
// the first tier is selected by default
await tierList.clickFirstItem("main-weapon");
await expect(tierList.locators.emptyTiersClickMode).toHaveCount(4);
// Selecting another tier and clicking a weapon places it there
await emptyTiers.first().click();
await page.locator('button[data-item-id^="main-weapon:"]').first().click();
await expect(emptyTiers).toHaveCount(3);
await tierList.selectFirstEmptyTier();
await tierList.clickFirstItem("main-weapon");
await expect(tierList.locators.emptyTiersClickMode).toHaveCount(3);
});
});
async function dragItemToTier(page: Page, item: Locator) {
await item.hover();
await page.mouse.down();
const tier = page.getByText("Drop items here").last();
const tierBox = await tier.boundingBox();
if (tierBox) {
await page.mouse.move(
tierBox.x + tierBox.width / 2,
tierBox.y + tierBox.height / 2,
{ steps: 10 },
);
}
await page.mouse.up();
await page.waitForTimeout(200);
}