mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 23:56:50 -05:00
Finish other
This commit is contained in:
parent
522c5b3977
commit
37ac6aa4a9
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
121
e2e/ban.spec.ts
121
e2e/ban.spec.ts
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
151
e2e/lfg.spec.ts
151
e2e/lfg.spec.ts
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
52
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] });
|
||||
}
|
||||
}
|
||||
74
e2e/pages/ban/admin-ban-page.ts
Normal file
74
e2e/pages/ban/admin-ban-page.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
e2e/pages/ban/suspended-page.ts
Normal file
20
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}`);
|
||||
}
|
||||
}
|
||||
56
e2e/pages/layout/mobile-nav.ts
Normal file
56
e2e/pages/layout/mobile-nav.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
35
e2e/pages/layout/side-nav.ts
Normal file
35
e2e/pages/layout/side-nav.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
30
e2e/pages/layout/top-nav-menus.ts
Normal file
30
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 });
|
||||
}
|
||||
}
|
||||
45
e2e/pages/lfg/lfg-page.ts
Normal file
45
e2e/pages/lfg/lfg-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
e2e/pages/lfg/new-lfg-post-page.ts
Normal file
32
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 "~/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);
|
||||
}
|
||||
}
|
||||
53
e2e/pages/scrims/new-scrim-post-page.ts
Normal file
53
e2e/pages/scrims/new-scrim-post-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
142
e2e/pages/scrims/scrim-page.ts
Normal file
142
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
e2e/pages/tier-list-maker/tier-list-maker-page.ts
Normal file
106
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();
|
||||
}
|
||||
}
|
||||
22
e2e/pages/tournament/tournament-team-page.ts
Normal file
22
e2e/pages/tournament/tournament-team-page.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }] },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user