mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
45
apps/web-react/e2e/helpers/counterpick-map-pool.ts
Normal file
45
apps/web-react/e2e/helpers/counterpick-map-pool.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
|
||||
/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */
|
||||
const FIRST_COUNTERPICK_STAGE_ID = 5;
|
||||
|
||||
/** A stage of a `ModeMapPoolPicker`, whether picked or not. */
|
||||
export function counterpickMap(page: Page, mode: ModeShort, stageId: StageId) {
|
||||
return page.getByTestId(`map-pool-${mode}-${stageId}`);
|
||||
}
|
||||
|
||||
/** The check overlaying a picked stage. It covers the stage itself, so unpicking goes through it. */
|
||||
export function pickedCounterpickMap(
|
||||
page: Page,
|
||||
mode: ModeShort,
|
||||
stageId: StageId,
|
||||
) {
|
||||
return page.getByTestId(`map-pool-${mode}-${stageId}-picked`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the required amount of counterpick maps for every ranked mode, skipping
|
||||
* banned ones, and returns what was picked.
|
||||
*/
|
||||
export async function pickCounterpickMaps(page: Page) {
|
||||
const picked: Array<{ mode: ModeShort; stageId: StageId }> = [];
|
||||
let stageId = FIRST_COUNTERPICK_STAGE_ID;
|
||||
|
||||
for (const mode of rankedModesShort) {
|
||||
for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) {
|
||||
while (BANNED_MAPS[mode].includes(stageId as StageId)) {
|
||||
stageId++;
|
||||
}
|
||||
|
||||
await counterpickMap(page, mode, stageId as StageId).click();
|
||||
picked.push({ mode, stageId: stageId as StageId });
|
||||
stageId++;
|
||||
}
|
||||
}
|
||||
|
||||
return picked;
|
||||
}
|
||||
135
apps/web-react/e2e/helpers/factories.ts
Normal file
135
apps/web-react/e2e/helpers/factories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { NZAP_TEST_DISCORD_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
|
||||
|
||||
export type Factories = Awaited<ReturnType<typeof loadFactories>>;
|
||||
|
||||
let boundDbPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Binds this worker's database file and hands back the factories. The only module
|
||||
* allowed to import seed code into the e2e process: the app db connection opens at
|
||||
* import time from `DB_PATH`, so everything reaching `~/db/sql` has to be imported
|
||||
* dynamically, after the env is set.
|
||||
*/
|
||||
export async function loadFactories(parallelIndex: number) {
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
throw new Error(
|
||||
"NODE_ENV=test makes the app db an in-memory copy — factory writes would be discarded",
|
||||
);
|
||||
}
|
||||
|
||||
// a `.env` value is fine to override, an earlier bind to another worker's file is not
|
||||
const dbPath = `db-test-e2e-${parallelIndex}.sqlite3`;
|
||||
if (boundDbPath && boundDbPath !== dbPath) {
|
||||
throw new Error(
|
||||
`The db connection is already bound to ${boundDbPath}, refusing to rebind to ${dbPath}`,
|
||||
);
|
||||
}
|
||||
process.env.DB_PATH = dbPath;
|
||||
boundDbPath = dbPath;
|
||||
|
||||
return {
|
||||
backdate: (await import("~/db/seed/core/backdate")).backdate,
|
||||
reseason: (await import("~/db/seed/core/reseason")).reseason,
|
||||
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
|
||||
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
|
||||
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),
|
||||
BadgeFactory: await import("~/db/seed/factories/BadgeFactory"),
|
||||
BuildFactory: await import("~/db/seed/factories/BuildFactory"),
|
||||
CalendarEventFactory: await import(
|
||||
"~/db/seed/factories/CalendarEventFactory"
|
||||
),
|
||||
FriendRequestFactory: await import(
|
||||
"~/db/seed/factories/FriendRequestFactory"
|
||||
),
|
||||
FriendshipFactory: await import("~/db/seed/factories/FriendshipFactory"),
|
||||
LFGPostFactory: await import("~/db/seed/factories/LFGPostFactory"),
|
||||
LiveStreamFactory: await import("~/db/seed/factories/LiveStreamFactory"),
|
||||
NotificationFactory: await import(
|
||||
"~/db/seed/factories/NotificationFactory"
|
||||
),
|
||||
SavedCalendarEventFactory: await import(
|
||||
"~/db/seed/factories/SavedCalendarEventFactory"
|
||||
),
|
||||
ScrimPostFactory: await import("~/db/seed/factories/ScrimPostFactory"),
|
||||
SkillFactory: await import("~/db/seed/factories/SkillFactory"),
|
||||
SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"),
|
||||
SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"),
|
||||
SQReadyCheckFactory: await import(
|
||||
"~/db/seed/factories/SQReadyCheckFactory"
|
||||
),
|
||||
TeamFactory: await import("~/db/seed/factories/TeamFactory"),
|
||||
TournamentFactory: await import("~/db/seed/factories/TournamentFactory"),
|
||||
TournamentOrganizationFactory: await import(
|
||||
"~/db/seed/factories/TournamentOrganizationFactory"
|
||||
),
|
||||
TournamentTeamFactory: await import(
|
||||
"~/db/seed/factories/TournamentTeamFactory"
|
||||
),
|
||||
TrophyFactory: await import("~/db/seed/factories/TrophyFactory"),
|
||||
UserFactory: await import("~/db/seed/factories/UserFactory"),
|
||||
UserReportFactory: await import("~/db/seed/factories/UserReportFactory"),
|
||||
VodFactory: await import("~/db/seed/factories/VodFactory"),
|
||||
XRankPlacementFactory: await import(
|
||||
"~/db/seed/factories/XRankPlacementFactory"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the worker's database to the state every test starts from: empty except
|
||||
* for the anchor users whose ids production permission logic keys off, with the
|
||||
* server's caches flushed. Runs as an auto-use fixture, tests do not call it.
|
||||
*/
|
||||
export async function resetForTest(page: Page, factories: Factories) {
|
||||
const { dbReset } = await import("~/db/reset");
|
||||
await dbReset();
|
||||
|
||||
await factories.UserFactory.createAdmin({
|
||||
discordId: ADMIN_DISCORD_ID,
|
||||
discordName: "Sendou",
|
||||
});
|
||||
await factories.UserFactory.createRegular({
|
||||
discordId: NZAP_TEST_DISCORD_ID,
|
||||
discordName: "N-ZAP",
|
||||
});
|
||||
|
||||
await flushIfDirty(page, { resetDevOverrides: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the server's in-process caches if anything was written since the last
|
||||
* flush. Called by the helpers that make the browser talk to the server — tests do
|
||||
* not call it themselves.
|
||||
*/
|
||||
export async function flushIfDirty(
|
||||
page: Page,
|
||||
{ resetDevOverrides = false } = {},
|
||||
) {
|
||||
const { isDatabaseDirty, markDatabaseClean } = await import(
|
||||
"~/db/write-tracker"
|
||||
);
|
||||
if (!isDatabaseDirty() && !resetDevOverrides) return;
|
||||
|
||||
// deliberately not retryPost: that helper calls this one, and would recurse
|
||||
const response = await page.request.post("/refresh-caches", {
|
||||
timeout: 7_500,
|
||||
form: { resetDevOverrides: String(resetDevOverrides) },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Cache refresh failed with status ${response.status()}`);
|
||||
}
|
||||
|
||||
markDatabaseClean();
|
||||
}
|
||||
|
||||
/** Fails the test if it ended with factory writes the server was never told about. */
|
||||
export async function assertFlushed() {
|
||||
const { isDatabaseDirty } = await import("~/db/write-tracker");
|
||||
if (!isDatabaseDirty()) return;
|
||||
|
||||
throw new Error(
|
||||
"Test ended with database writes the server never saw — follow factory calls with a helper that talks to the server (navigate, submit, impersonate)",
|
||||
);
|
||||
}
|
||||
273
apps/web-react/e2e/helpers/playwright-form.ts
Normal file
273
apps/web-react/e2e/helpers/playwright-form.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { z } from "zod";
|
||||
import { getFormFieldMetadata } from "~/form/fields";
|
||||
import type { FormField } from "~/form/types";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadTranslations(): Record<string, Record<string, string>> {
|
||||
const localesPath = path.resolve(__dirname, "../../locales/en");
|
||||
return {
|
||||
forms: JSON.parse(
|
||||
fs.readFileSync(path.join(localesPath, "forms.json"), "utf-8"),
|
||||
),
|
||||
common: JSON.parse(
|
||||
fs.readFileSync(path.join(localesPath, "common.json"), "utf-8"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const translations = loadTranslations();
|
||||
|
||||
function resolveTranslation(key: string): string {
|
||||
// Handle keys like "common:forms.name" or "team:newTeam.header"
|
||||
const [namespace, translationPath] = key.includes(":")
|
||||
? key.split(":")
|
||||
: ["common", key];
|
||||
const nsTranslations = translations[namespace];
|
||||
|
||||
if (!nsTranslations) {
|
||||
return key;
|
||||
}
|
||||
|
||||
const value = nsTranslations[translationPath as keyof typeof nsTranslations];
|
||||
return typeof value === "string" ? value : key;
|
||||
}
|
||||
|
||||
type Inferred<T extends z.ZodRawShape> = z.infer<z.ZodObject<T>>;
|
||||
|
||||
type FillableKeys<T extends z.ZodRawShape> = {
|
||||
[K in keyof Inferred<T>]-?: string extends Inferred<T>[K] ? K : never;
|
||||
}[keyof Inferred<T>];
|
||||
|
||||
type CheckableKeys<T extends z.ZodRawShape> = {
|
||||
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends boolean ? K : never;
|
||||
}[keyof Inferred<T>];
|
||||
|
||||
type SelectableKeys<T extends z.ZodRawShape> = {
|
||||
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends string | null | undefined
|
||||
? K
|
||||
: never;
|
||||
}[keyof Inferred<T>];
|
||||
|
||||
type FormFieldHelpers<T extends z.ZodRawShape> = {
|
||||
fill: (name: FillableKeys<T>, value: string) => Promise<void>;
|
||||
check: (name: CheckableKeys<T>) => Promise<void>;
|
||||
uncheck: (name: CheckableKeys<T>) => Promise<void>;
|
||||
checkItems: (name: keyof Inferred<T>, itemValues: string[]) => Promise<void>;
|
||||
select: (name: SelectableKeys<T>, optionText: string) => Promise<void>;
|
||||
selectUser: (name: keyof Inferred<T>, userName: string) => Promise<void>;
|
||||
selectWeapons: (
|
||||
name: keyof Inferred<T>,
|
||||
weaponNames: string[],
|
||||
) => Promise<void>;
|
||||
setDateTime: (name: keyof Inferred<T>, date: Date) => Promise<void>;
|
||||
setDate: (name: keyof Inferred<T>, date: Date) => Promise<void>;
|
||||
setImage: (name: keyof Inferred<T>, filePath: string) => Promise<void>;
|
||||
submit: () => Promise<void>;
|
||||
getLabel: <K extends keyof Inferred<T>>(name: K) => string;
|
||||
getItemLabel: (name: keyof Inferred<T>, itemValue: string) => string;
|
||||
};
|
||||
|
||||
export function createFormHelpers<T extends z.ZodRawShape>(
|
||||
page: Page,
|
||||
schema: z.ZodObject<T>,
|
||||
options?: { submitTestId?: string },
|
||||
): FormFieldHelpers<T> {
|
||||
const submitTestId = options?.submitTestId ?? "submit-button";
|
||||
const getFieldMetadata = (name: string): FormField | undefined => {
|
||||
const fieldSchema = schema.shape[name];
|
||||
if (!fieldSchema) return undefined;
|
||||
return getFormFieldMetadata(fieldSchema as z.ZodType);
|
||||
};
|
||||
|
||||
const getLabel = (name: string): string => {
|
||||
const metadata = getFieldMetadata(name);
|
||||
if (!metadata || !("label" in metadata) || !metadata.label) {
|
||||
throw new Error(`No label found for field: ${name}`);
|
||||
}
|
||||
return resolveTranslation(metadata.label);
|
||||
};
|
||||
|
||||
// match the whole label (allowing the trailing space and optional required " *" the
|
||||
// Label component always renders) so a short label like "Name" doesn't also pick up
|
||||
// "Bracket name" or "Require in-game names"
|
||||
const byLabel = (label: string) => {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return page.getByLabel(new RegExp(`^${escaped} *\\*?$`, "i"));
|
||||
};
|
||||
|
||||
const getItemLabel = (name: string, itemValue: string): string => {
|
||||
const metadata = getFieldMetadata(name);
|
||||
if (!metadata || !("items" in metadata) || !Array.isArray(metadata.items)) {
|
||||
throw new Error(`No items found for field: ${name}`);
|
||||
}
|
||||
const item = metadata.items.find(
|
||||
(i: { value: string }) => i.value === itemValue,
|
||||
);
|
||||
if (!item || typeof item.label !== "string") {
|
||||
throw new Error(`No item found with value: ${itemValue}`);
|
||||
}
|
||||
return resolveTranslation(item.label);
|
||||
};
|
||||
|
||||
return {
|
||||
getLabel(name) {
|
||||
return getLabel(String(name));
|
||||
},
|
||||
|
||||
getItemLabel(name, itemValue) {
|
||||
return getItemLabel(String(name), itemValue);
|
||||
},
|
||||
|
||||
async fill(name, value) {
|
||||
const label = getLabel(String(name));
|
||||
await byLabel(label).fill(value);
|
||||
},
|
||||
|
||||
async check(name) {
|
||||
const label = getLabel(String(name));
|
||||
const locator = byLabel(label);
|
||||
const isChecked = await locator.isChecked();
|
||||
if (!isChecked) {
|
||||
await locator.click({ force: true });
|
||||
}
|
||||
},
|
||||
|
||||
async uncheck(name) {
|
||||
const label = getLabel(String(name));
|
||||
const locator = byLabel(label);
|
||||
const isChecked = await locator.isChecked();
|
||||
if (isChecked) {
|
||||
await locator.click({ force: true });
|
||||
}
|
||||
},
|
||||
|
||||
async checkItems(name, itemValues) {
|
||||
const metadata = getFieldMetadata(String(name));
|
||||
if (
|
||||
!metadata ||
|
||||
!("items" in metadata) ||
|
||||
!Array.isArray(metadata.items)
|
||||
) {
|
||||
throw new Error(`No items found for field: ${String(name)}`);
|
||||
}
|
||||
|
||||
for (const item of metadata.items as Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>) {
|
||||
const itemLabelText = resolveTranslation(item.label);
|
||||
const locator = page.getByLabel(itemLabelText);
|
||||
const isChecked = await locator.isChecked();
|
||||
const shouldBeChecked = itemValues.includes(item.value);
|
||||
|
||||
if (shouldBeChecked && !isChecked) {
|
||||
await locator.click();
|
||||
} else if (!shouldBeChecked && isChecked) {
|
||||
await locator.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async select(name, optionValue) {
|
||||
const label = getLabel(String(name));
|
||||
const locator = byLabel(label);
|
||||
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
|
||||
|
||||
const metadata = getFieldMetadata(String(name));
|
||||
let resolvedOptionText = optionValue;
|
||||
if (metadata && "items" in metadata && Array.isArray(metadata.items)) {
|
||||
const item = metadata.items.find(
|
||||
(i: { value: string }) => i.value === optionValue,
|
||||
);
|
||||
if (item && typeof item.label === "string") {
|
||||
resolvedOptionText = resolveTranslation(item.label);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName === "select") {
|
||||
await locator.selectOption(resolvedOptionText);
|
||||
} else {
|
||||
await locator.click();
|
||||
await page.getByRole("option", { name: resolvedOptionText }).click();
|
||||
}
|
||||
},
|
||||
|
||||
async selectUser(name, userName) {
|
||||
const label = getLabel(String(name));
|
||||
// role + non-exact name: the trigger's accessible name is e.g. "User search User *"
|
||||
const comboboxButton = page.getByRole("button", { name: label });
|
||||
const searchInput = page.getByTestId("user-search-input");
|
||||
const option = page.getByTestId("user-search-item").first();
|
||||
|
||||
await expect(comboboxButton).not.toBeDisabled();
|
||||
await comboboxButton.click();
|
||||
await searchInput.fill(userName);
|
||||
await expect(option).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
},
|
||||
|
||||
async selectWeapons(_name, weaponNames) {
|
||||
for (const weaponName of weaponNames) {
|
||||
await page.getByTestId("weapon-select").click();
|
||||
await page.getByPlaceholder("Search weapons...").fill(weaponName);
|
||||
await page
|
||||
.getByRole("listbox", { name: "Suggestions" })
|
||||
.getByTestId(`weapon-select-option-${weaponName}`)
|
||||
.click();
|
||||
}
|
||||
},
|
||||
|
||||
async setDateTime(name, date) {
|
||||
const label = getLabel(String(name));
|
||||
const hours = date.getHours();
|
||||
|
||||
const fillSpinbutton = async (spinName: string, value: string) => {
|
||||
await page
|
||||
.getByRole("spinbutton", {
|
||||
name: new RegExp(`^${spinName}, ${label}`),
|
||||
})
|
||||
.fill(value);
|
||||
};
|
||||
|
||||
await fillSpinbutton("year", date.getFullYear().toString());
|
||||
await fillSpinbutton("month", (date.getMonth() + 1).toString());
|
||||
await fillSpinbutton("day", date.getDate().toString());
|
||||
await fillSpinbutton("hour", String(hours % 12 || 12));
|
||||
await fillSpinbutton(
|
||||
"minute",
|
||||
date.getMinutes().toString().padStart(2, "0"),
|
||||
);
|
||||
await fillSpinbutton("AM/PM", hours >= 12 ? "PM" : "AM");
|
||||
},
|
||||
|
||||
async setDate(name, date) {
|
||||
const label = getLabel(String(name));
|
||||
|
||||
const fillSpinbutton = async (spinName: string, value: string) => {
|
||||
await page
|
||||
.getByRole("spinbutton", {
|
||||
name: new RegExp(`^${spinName}, ${label}`),
|
||||
})
|
||||
.fill(value);
|
||||
};
|
||||
|
||||
await fillSpinbutton("year", date.getFullYear().toString());
|
||||
await fillSpinbutton("month", (date.getMonth() + 1).toString());
|
||||
await fillSpinbutton("day", date.getDate().toString());
|
||||
},
|
||||
|
||||
async setImage(name, filePath) {
|
||||
const label = getLabel(String(name));
|
||||
await page.getByLabel(label).setInputFiles(filePath);
|
||||
},
|
||||
|
||||
async submit() {
|
||||
await page.getByTestId(submitTestId).click();
|
||||
},
|
||||
};
|
||||
}
|
||||
365
apps/web-react/e2e/helpers/playwright.ts
Normal file
365
apps/web-react/e2e/helpers/playwright.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Response,
|
||||
} from "@playwright/test";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import {
|
||||
assertFlushed,
|
||||
type Factories,
|
||||
flushIfDirty,
|
||||
loadFactories,
|
||||
resetForTest,
|
||||
} from "./factories";
|
||||
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// .env is optional; in CI env vars come from the host (e2e-tests.yml creates none)
|
||||
}
|
||||
export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500;
|
||||
|
||||
export const MOBILE_VIEWPORT = { width: 375, height: 667 };
|
||||
export const TABLET_VIEWPORT = { width: 768, height: 1024 };
|
||||
|
||||
/** Registered (>=1024) ports on the WHATWG fetch bad port list: Node's fetch
|
||||
* fails on them with "bad port" and Chromium with ERR_UNSAFE_PORT, so no worker
|
||||
* server may listen on one (e.g. base port 6673 would put worker 6 on 6679). */
|
||||
const UNSAFE_PORTS = new Set([
|
||||
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 5432, 5500, 5938, 6000,
|
||||
6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
]);
|
||||
|
||||
/** The port of the given worker's server: base port + index, skipping unsafe ports. */
|
||||
export function e2eWorkerPort(workerIndex: number) {
|
||||
let port = E2E_BASE_PORT - 1;
|
||||
for (let i = 0; i <= workerIndex; i++) {
|
||||
do {
|
||||
port++;
|
||||
} while (UNSAFE_PORTS.has(port));
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/** The port a test can listen on to receive the given worker's Discord webhook calls. */
|
||||
export function e2eWebhookPort(workerIndex: number) {
|
||||
return e2eWorkerPort(workerIndex) + 2000;
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
workerPort: number;
|
||||
workerBaseURL: string;
|
||||
factories: Factories;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
resetDatabase: undefined;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
context: async ({ context }, use) => {
|
||||
// Google Fonts load with display=swap and every test context re-fetches
|
||||
// them, so the swap reflows the page mid-test (e.g. re-collapsing the
|
||||
// tournament nav between a visibility check and a click). Block them so
|
||||
// layout settles at first paint and stays put.
|
||||
await context.route(
|
||||
/^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
|
||||
(route) => route.abort(),
|
||||
);
|
||||
await use(context);
|
||||
},
|
||||
workerPort: [
|
||||
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring
|
||||
async ({}, use, workerInfo) => {
|
||||
const port = e2eWorkerPort(workerInfo.parallelIndex);
|
||||
await use(port);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
workerBaseURL: [
|
||||
async ({ workerPort }, use) => {
|
||||
await use(`http://localhost:${workerPort}`);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
baseURL: async ({ workerBaseURL }, use) => {
|
||||
await use(workerBaseURL);
|
||||
},
|
||||
factories: [
|
||||
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring
|
||||
async ({}, use, workerInfo) => {
|
||||
await use(await loadFactories(workerInfo.parallelIndex));
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
resetDatabase: [
|
||||
async ({ page, factories }, use) => {
|
||||
await resetForTest(page, factories);
|
||||
|
||||
await use(undefined);
|
||||
|
||||
// fails loudly instead of leaving the next test to guess
|
||||
await assertFlushed();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
export async function selectWeapon({
|
||||
page,
|
||||
name,
|
||||
testId = "weapon-select",
|
||||
}: {
|
||||
page: Page;
|
||||
name: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
await page.getByTestId(testId).click();
|
||||
await page.getByPlaceholder("Search weapons...").fill(name);
|
||||
await page
|
||||
.getByRole("listbox", { name: "Suggestions" })
|
||||
.getByTestId(`weapon-select-option-${name}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function selectStage({
|
||||
page,
|
||||
name,
|
||||
testId = "stage-select",
|
||||
nth,
|
||||
}: {
|
||||
page: Page;
|
||||
name: string;
|
||||
testId?: string;
|
||||
nth?: number;
|
||||
}) {
|
||||
const select =
|
||||
nth !== undefined
|
||||
? page.getByTestId(testId).nth(nth)
|
||||
: page.getByTestId(testId);
|
||||
await select.click();
|
||||
await page.getByPlaceholder("Search stages...").fill(name);
|
||||
await page.getByTestId(`stage-select-option-${name}`).click();
|
||||
}
|
||||
|
||||
export async function selectUser({
|
||||
page,
|
||||
userName,
|
||||
labelName,
|
||||
exact = false,
|
||||
within,
|
||||
}: {
|
||||
page: Page;
|
||||
userName: string;
|
||||
labelName: string;
|
||||
exact?: boolean;
|
||||
/** Scopes the combobox lookup, for pages carrying the label on more than one element. */
|
||||
within?: Locator;
|
||||
}) {
|
||||
const comboboxButton = (within ?? page).getByLabel(labelName, { exact });
|
||||
const searchInput = page.getByTestId("user-search-input");
|
||||
const option = page.getByTestId("user-search-item").first();
|
||||
|
||||
await expect(comboboxButton).not.toBeDisabled();
|
||||
|
||||
await comboboxButton.click();
|
||||
await searchInput.fill(userName);
|
||||
await expect(option).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function selectTournament({
|
||||
page,
|
||||
query,
|
||||
}: {
|
||||
page: Page;
|
||||
query: string;
|
||||
}) {
|
||||
const item = page.getByTestId("tournament-search-item");
|
||||
|
||||
await page.getByRole("button", { name: /Tournament search/i }).click();
|
||||
await page.getByTestId("tournament-search-input").fill(query);
|
||||
await expect(item.first()).toBeVisible();
|
||||
await item.first().click();
|
||||
}
|
||||
|
||||
/** page.goto that waits for the page to be hydrated before proceeding */
|
||||
export async function navigate({ page, url }: { page: Page; url: string }) {
|
||||
await flushIfDirty(page);
|
||||
|
||||
// Rewrite absolute URLs with localhost to use the worker's baseURL
|
||||
// This handles invite links and other URLs embedded with VITE_SITE_DOMAIN
|
||||
let targetUrl = url;
|
||||
if (url.startsWith("http://localhost:")) {
|
||||
const urlObj = new URL(url);
|
||||
// Extract just the path and search params, let Playwright use the correct baseURL
|
||||
targetUrl = urlObj.pathname + urlObj.search;
|
||||
}
|
||||
await page.goto(targetUrl);
|
||||
await expectIsHydrated(page);
|
||||
}
|
||||
|
||||
/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */
|
||||
export async function expectIsHydrated(page: Page) {
|
||||
await expect(page.getByTestId("hydrated")).toHaveCount(1);
|
||||
}
|
||||
|
||||
export function impersonate(page: Page, userId = ADMIN_ID) {
|
||||
return retryPost(page, "impersonate", `/auth/impersonate?id=${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the worker's server resolve every season as over, so tests can cover the
|
||||
* season boundary. Undone before the next test starts.
|
||||
*/
|
||||
export async function endSeason(page: Page) {
|
||||
const response = await retryPost(page, "endSeason", "/end-season");
|
||||
if (!response?.ok()) {
|
||||
throw new Error(
|
||||
`Ending the season failed with status ${response?.status()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the named server Routine (normally cron-driven) in the worker's server process. */
|
||||
export async function runRoutine(page: Page, name: string) {
|
||||
const response = await retryPost(page, "runRoutine", "/run-routine", {
|
||||
form: { name },
|
||||
});
|
||||
if (!response?.ok()) {
|
||||
throw new Error(
|
||||
`Running routine ${name} failed with status ${response?.status()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct (non-browser) POST that retries on transient network failures such as
|
||||
* "socket hang up", which the dev server can produce intermittently under load.
|
||||
* Only safe for idempotent endpoints.
|
||||
*/
|
||||
async function retryPost(
|
||||
page: Page,
|
||||
name: string,
|
||||
url: string,
|
||||
options?: Parameters<Page["request"]["post"]>[1],
|
||||
) {
|
||||
await flushIfDirty(page);
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await page.request.post(url, { timeout: 7_500, ...options });
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${name}: unreachable`);
|
||||
}
|
||||
|
||||
/** Clicks a submit button and waits for the POST it fires. Takes a locator when
|
||||
* the test id alone is ambiguous, e.g. one button per card on a list page. */
|
||||
export async function submit(page: Page, target?: string | Locator) {
|
||||
const button =
|
||||
typeof target === "object"
|
||||
? target
|
||||
: page.getByTestId(target ?? "submit-button");
|
||||
|
||||
await waitForPOSTResponse(page, async () => {
|
||||
await button.click();
|
||||
});
|
||||
|
||||
// Toast flash params are stripped right after via a replace navigation
|
||||
// (without revalidation); wait for it so it can't abort a later click.
|
||||
await expect(page).not.toHaveURL(/__(?:success|error)=/);
|
||||
}
|
||||
|
||||
export async function waitForPOSTResponse(page: Page, cb: () => Promise<void>) {
|
||||
await flushIfDirty(page);
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const PER_ATTEMPT_TIMEOUT = 10_000;
|
||||
|
||||
// React Aria buttons fire their handler on press end. Occasionally a click
|
||||
// registers the press start (the button goes `:active`) but the press never
|
||||
// completes into a submit, so no POST fires — e.g. when a re-render lands
|
||||
// mid-press. Re-issue the action when the expected POST doesn't arrive
|
||||
// within the per-attempt window.
|
||||
let response: Response | undefined;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(res) => res.request().method() === "POST",
|
||||
{ timeout: PER_ATTEMPT_TIMEOUT },
|
||||
);
|
||||
await cb();
|
||||
try {
|
||||
response = await responsePromise;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The POST's revalidation (and any redirect it drives) is still in flight;
|
||||
// an interaction landing mid-flight aborts it, and routes that opt out of
|
||||
// revalidation on navigation (e.g. to.$id) then keep the stale data.
|
||||
await expectRouterIdle(page);
|
||||
|
||||
return response!;
|
||||
}
|
||||
|
||||
/** Waits until no navigation, revalidation or fetcher is in flight. */
|
||||
async function expectRouterIdle(page: Page) {
|
||||
// A submit's redirect plus the target page's loaders can exceed the default
|
||||
// expect timeout when the full suite is loading all workers.
|
||||
try {
|
||||
await expect(page.getByTestId("hydrated")).toHaveAttribute(
|
||||
"data-router-idle",
|
||||
"true",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
// data-router-busy names what is still in flight, which the attribute
|
||||
// assertion's own message does not
|
||||
const busy = await page
|
||||
.getByTestId("hydrated")
|
||||
.getAttribute("data-router-busy")
|
||||
.catch(() => null);
|
||||
|
||||
throw new Error(
|
||||
`Router never went idle at ${page.url()} (in flight: ${busy ?? "unknown"})`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts the page rendered rather than the error boundary catching something. */
|
||||
export async function expectNoErrorPage(page: Page) {
|
||||
await expect(page.getByTestId("error-page")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function isNotVisible(locator: Locator) {
|
||||
return expect(locator).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function modalClickConfirmButton(page: Page) {
|
||||
return submit(page, "confirm-button");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a tournament nav tab by its testId, opening the overflow ("More") menu
|
||||
* first when the tab has collapsed into it on the current viewport.
|
||||
*/
|
||||
export async function clickNavTab(page: Page, testId: string) {
|
||||
const visibleTab = page.locator(`[data-testid="${testId}"]:visible`);
|
||||
if ((await visibleTab.count()) === 0) {
|
||||
await page.getByRole("button", { name: "More" }).click();
|
||||
}
|
||||
await visibleTab.click();
|
||||
}
|
||||
73
apps/web-react/e2e/helpers/sidebar.ts
Normal file
73
apps/web-react/e2e/helpers/sidebar.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { addDays, addHours } from "date-fns";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Factories } from "./factories";
|
||||
import { expect } from "./playwright";
|
||||
|
||||
export const NO_EVENTS_TEXT = "No upcoming events";
|
||||
export const NO_STREAMS_TEXT = "No streams currently";
|
||||
export const NO_FRIENDS_TEXT = "Add friends to see their activity here";
|
||||
export const LOGGED_OUT_FRIENDS_TEXT =
|
||||
"Log in to follow your friends' activity";
|
||||
|
||||
export function inHours(hours: number) {
|
||||
return dateToDatabaseTimestamp(addHours(new Date(), hours));
|
||||
}
|
||||
|
||||
export function inDays(days: number) {
|
||||
return dateToDatabaseTimestamp(addDays(new Date(), days));
|
||||
}
|
||||
|
||||
/** The Twitch account `LiveStreamFactory` gives a user's stream. */
|
||||
export function streamAccountOf(userId: number) {
|
||||
return `stream_${userId}`;
|
||||
}
|
||||
|
||||
/** Users the sidebar rows can be found by, one per name. */
|
||||
export function createNamedUsers(
|
||||
factories: Factories,
|
||||
names: string[],
|
||||
overrides?: { twitch?: string },
|
||||
) {
|
||||
return factories.UserFactory.createMany(names.length, (index) => ({
|
||||
...overrides,
|
||||
discordName: names[index],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createUserIds(factories: Factories, count: number) {
|
||||
const users = await factories.UserFactory.createMany(count);
|
||||
|
||||
return users.map((user) => user.id);
|
||||
}
|
||||
|
||||
export function befriend(
|
||||
factories: Factories,
|
||||
friendIds: number[],
|
||||
userId = NZAP_TEST_ID,
|
||||
) {
|
||||
return Promise.all(
|
||||
friendIds.map((friendId) =>
|
||||
factories.FriendshipFactory.create({
|
||||
userOneId: userId,
|
||||
userTwoId: friendId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Asserts the rows are rendered in the order given, top to bottom. */
|
||||
export async function expectTopToBottom(rows: Locator[]) {
|
||||
const tops: number[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
invariant(box, "Visible row has no bounding box");
|
||||
tops.push(box.y);
|
||||
}
|
||||
|
||||
expect(tops).toEqual([...tops].sort((a, b) => a - b));
|
||||
}
|
||||
293
apps/web-react/e2e/helpers/tournament.ts
Normal file
293
apps/web-react/e2e/helpers/tournament.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { addHours, subMinutes } from "date-fns";
|
||||
import type { TournamentSettings } from "~/db/tables-json";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Factories } from "./factories";
|
||||
|
||||
export const ROSTER_SIZE = 4;
|
||||
|
||||
type BracketProgression = TournamentSettings["bracketProgression"];
|
||||
|
||||
export const DOUBLE_ELIMINATION: BracketProgression = [
|
||||
{
|
||||
type: "double_elimination",
|
||||
name: "Main bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
];
|
||||
|
||||
/* Single group of 4 teams plays: R1 = matches 1 (team 1 vs. 4) & 2 (team 3 vs. 2),
|
||||
* R2 = matches 3 (team 2 vs. 4) & 4 (team 1 vs. 3), R3 = matches 5 & 6 (team 2 vs. 1). */
|
||||
export const ROUND_ROBIN: BracketProgression = [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const RR_TO_SE: BracketProgression = [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Final stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2] }],
|
||||
},
|
||||
];
|
||||
|
||||
/** With 16 teams: 4 round robin groups of 4 where every team advances to a 16 team
|
||||
* single elimination — the format shape of GitHub issue #2607. */
|
||||
export const RR_TOP_4_TO_SE: BracketProgression = [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Final stage",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
export const RR_TO_SE_WITH_UNDERGROUND: BracketProgression = [
|
||||
...RR_TO_SE,
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Underground bracket",
|
||||
requiresCheckIn: true,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [3, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
export const SOS_BRACKETS: BracketProgression = [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Great White",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [1] }],
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Hammerhead",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [2] }],
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Mako",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [3] }],
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Lantern",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [4] }],
|
||||
},
|
||||
];
|
||||
|
||||
export const SWISS_TO_TOP_CUT: BracketProgression = [
|
||||
{
|
||||
type: "swiss",
|
||||
name: "Swiss",
|
||||
requiresCheckIn: false,
|
||||
settings: { groupCount: 2, roundCount: 4 },
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Top Cut",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Teams advance at 2 wins and are eliminated at 4 losses (roundCount - advanceThreshold + 1). */
|
||||
export const SWISS_EARLY_ADVANCE_TO_TOP_CUT: BracketProgression = [
|
||||
{
|
||||
type: "swiss",
|
||||
name: "Swiss",
|
||||
requiresCheckIn: false,
|
||||
settings: { groupCount: 1, roundCount: 5, advanceThreshold: 2 },
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Top Cut",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
sources: [{ bracketIdx: 0, placements: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
export const TO_MAP_POOL = ([1, 2, 3, 4, 6, 7, 8, 10] as StageId[]).flatMap(
|
||||
(stageId) =>
|
||||
(["SZ", "TC", "RM", "CB"] as ModeShort[]).map((mode) => ({
|
||||
mode,
|
||||
stageId,
|
||||
})),
|
||||
);
|
||||
|
||||
export type TeamSeed = {
|
||||
/** Users put on the roster ahead of freshly created filler users. */
|
||||
members?: number[];
|
||||
rosterSize?: number;
|
||||
isCheckedIn?: boolean;
|
||||
};
|
||||
|
||||
/** `count` checked in teams with full rosters of fresh users. */
|
||||
export function teamSeeds(count: number): TeamSeed[] {
|
||||
return Array.from({ length: count }, () => ({}));
|
||||
}
|
||||
|
||||
/** Registers a team per seed, named by seeding order ("Team 1", "Team 2", ...). */
|
||||
export async function createTeams(
|
||||
factories: Factories,
|
||||
tournamentId: number,
|
||||
seeds: TeamSeed[],
|
||||
) {
|
||||
const teams = [];
|
||||
for (const [i, seed] of seeds.entries()) {
|
||||
const presetMembers = seed.members ?? [];
|
||||
const rosterSize = seed.rosterSize ?? ROSTER_SIZE;
|
||||
const fillerUsers = await factories.UserFactory.createMany(
|
||||
rosterSize - presetMembers.length,
|
||||
);
|
||||
teams.push(
|
||||
await factories.TournamentTeamFactory.create(
|
||||
{
|
||||
tournamentId,
|
||||
team: {
|
||||
name: `Team ${i + 1}`,
|
||||
prefersNotToHost: 0 as const,
|
||||
teamId: null,
|
||||
},
|
||||
memberUserIds: [
|
||||
...presetMembers,
|
||||
...fillerUsers.map((user) => user.id),
|
||||
],
|
||||
},
|
||||
{ isCheckedIn: seed.isCheckedIn ?? true },
|
||||
),
|
||||
);
|
||||
}
|
||||
return teams;
|
||||
}
|
||||
|
||||
/** A start time in the past: check-in is over and brackets can be started from the UI. */
|
||||
export function startedTournamentTimes() {
|
||||
return [dateToDatabaseTimestamp(subMinutes(new Date(), 30))];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tournament running an unfinished match: two checked-in teams and the bracket
|
||||
* started, which is all it takes for both teams to be "in a match". The friend and
|
||||
* their teammate play the opponent, and every user given is on the roster it is
|
||||
* named for.
|
||||
*/
|
||||
export async function createInProgressMatch(
|
||||
factories: Factories,
|
||||
{
|
||||
name,
|
||||
friendId,
|
||||
teammateId,
|
||||
opponentId,
|
||||
tier,
|
||||
}: {
|
||||
name: string;
|
||||
friendId: number;
|
||||
teammateId?: number;
|
||||
opponentId?: number;
|
||||
tier?: TournamentTierNumber;
|
||||
},
|
||||
) {
|
||||
const tournament = await factories.TournamentFactory.create(
|
||||
{
|
||||
authorId: ADMIN_ID,
|
||||
name,
|
||||
startTimes: startedTournamentTimes(),
|
||||
},
|
||||
{ tier },
|
||||
);
|
||||
|
||||
const teams = await createTeams(factories, tournament.id, [
|
||||
{ members: [friendId, ...(teammateId ? [teammateId] : [])] },
|
||||
{ members: opponentId ? [opponentId] : [] },
|
||||
]);
|
||||
|
||||
const [match] = await factories.TournamentFactory.startBracket(tournament.id);
|
||||
invariant(match, "Starting the bracket created no match");
|
||||
|
||||
return { tournament, teams, matchId: match.id };
|
||||
}
|
||||
|
||||
/** An upcoming tournament where the given user's team is looking for subs. */
|
||||
export async function createSubSeekingTournament(
|
||||
factories: Factories,
|
||||
{ name, subId }: { name: string; subId: number },
|
||||
) {
|
||||
const tournament = await factories.TournamentFactory.create({
|
||||
authorId: ADMIN_ID,
|
||||
name,
|
||||
startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
|
||||
});
|
||||
|
||||
await factories.TournamentTeamFactory.create(
|
||||
{ tournamentId: tournament.id, memberUserIds: [subId] },
|
||||
{ isLooking: true },
|
||||
);
|
||||
|
||||
return { tournament };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tournament where the given user is waiting for their next match: three teams
|
||||
* in a single elimination bracket leave the top seed with a bye into the final,
|
||||
* whose other side is still being played for.
|
||||
*/
|
||||
export async function createTournamentWithByeTeam(
|
||||
factories: Factories,
|
||||
{ name, waitingUserId }: { name: string; waitingUserId: number },
|
||||
) {
|
||||
const tournament = await factories.TournamentFactory.create({
|
||||
authorId: ADMIN_ID,
|
||||
name,
|
||||
startTimes: startedTournamentTimes(),
|
||||
});
|
||||
|
||||
await createTeams(factories, tournament.id, [
|
||||
{ members: [waitingUserId] },
|
||||
{},
|
||||
{},
|
||||
]);
|
||||
|
||||
await factories.TournamentFactory.startBracket(tournament.id);
|
||||
|
||||
return { tournament };
|
||||
}
|
||||
Reference in New Issue
Block a user