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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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