diff --git a/.gitignore b/.gitignore index 31d8e9937..73bf74862 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ notes.md db*.sqlite3* !db-test.sqlite3 +!e2e/seeds/*.sqlite3 dump .DS_Store diff --git a/app/db/seed/constants.ts b/app/db/seed/constants.ts index f6f08158d..595c7dd4b 100644 --- a/app/db/seed/constants.ts +++ b/app/db/seed/constants.ts @@ -4,5 +4,6 @@ export const NZAP_TEST_DISCORD_ID = "455039198672453645"; export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://cdn.discordapp.com/avatars/455039198672453645/f809176af93132c3db5f0a5019e96339.webp?size=160 export const NZAP_TEST_ID = 2; export const REGULAR_USER_TEST_ID = 2; +export const ORG_ADMIN_TEST_ID = 3; export const AMOUNT_OF_CALENDAR_EVENTS = 200; diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 3ffb90d3e..fc76be34a 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -86,6 +86,7 @@ import { NZAP_TEST_AVATAR, NZAP_TEST_DISCORD_ID, NZAP_TEST_ID, + ORG_ADMIN_TEST_ID, } from "./constants"; import placements from "./placements.json"; @@ -739,7 +740,10 @@ function patrons() { .all() as any[] ) .map((u) => u.id) - .filter((id) => id !== NZAP_TEST_ID && id !== ADMIN_ID) as number[]; + .filter( + (id) => + id !== NZAP_TEST_ID && id !== ADMIN_ID && id !== ORG_ADMIN_TEST_ID, + ) as number[]; const givePatronStm = sql.prepare( `update user set "patronTier" = $patronTier, "patronSince" = $patronSince where id = $id`, @@ -2688,7 +2692,7 @@ async function organization() { roleDisplayName: null, }, { - userId: 3, + userId: ORG_ADMIN_TEST_ID, role: "ADMIN", roleDisplayName: null, }, diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index 72095e185..d1e70df6e 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -1,12 +1,19 @@ +import fs from "node:fs"; +import path from "node:path"; import type { ActionFunction } from "react-router"; import { z } from "zod"; -import { seed } from "~/db/seed"; +import { sql } from "~/db/sql"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { SEED_VARIATIONS } from "~/features/api-private/constants"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; +import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import { cache } from "~/utils/cache.server"; +import { logger } from "~/utils/logger"; import { parseRequestPayload } from "~/utils/remix.server"; +const E2E_SEEDS_DIR = "e2e/seeds"; + const seedSchema = z.object({ variation: z.enum(SEED_VARIATIONS).nullish(), }); @@ -25,10 +32,99 @@ export const action: ActionFunction = async ({ request }) => { schema: seedSchema, }); - await seed(variation); + const variationName = variation ?? "DEFAULT"; + const preSeededDbPath = path.join( + E2E_SEEDS_DIR, + `db-seed-${variationName}.sqlite3`, + ); + if (!fs.existsSync(preSeededDbPath)) { + // Fall back to slow seed if pre-seeded db doesn't exist + logger.warn( + `Pre-seeded database not found for variation "${variationName}", falling back to seeding via code.`, + ); + const { seed } = await import("~/db/seed"); + await seed(variation); + } else { + restoreFromPreSeeded(preSeededDbPath); + adjustSeedDatesToCurrent(variationName); + } + + clearAllTournamentDataCache(); + cache.clear(); await refreshBannedCache(); await refreshSendouQInstance(); return Response.json(null); }; + +const REG_OPEN_TOURNAMENT_IDS = [1, 3]; + +function adjustSeedDatesToCurrent(variation: SeedVariation) { + const halfAnHourFromNow = Math.floor((Date.now() + 1000 * 60 * 30) / 1000); + const oneHourAgo = Math.floor((Date.now() - 1000 * 60 * 60) / 1000); + const now = Math.floor(Date.now() / 1000); + + const tournamentEventIds = sql + .prepare( + `SELECT id, tournamentId FROM "CalendarEvent" WHERE tournamentId IS NOT NULL`, + ) + .all() as Array<{ id: number; tournamentId: number }>; + + for (const { id, tournamentId } of tournamentEventIds) { + const isRegOpen = + variation === "REG_OPEN" && + REG_OPEN_TOURNAMENT_IDS.includes(tournamentId); + + sql + .prepare(`UPDATE "CalendarEventDate" SET startTime = ? WHERE eventId = ?`) + .run(isRegOpen ? halfAnHourFromNow : oneHourAgo, id); + } + + sql + .prepare( + `UPDATE "Group" SET latestActionAt = ?, createdAt = ? WHERE status != 'INACTIVE'`, + ) + .run(now, now); + + sql.prepare(`UPDATE "GroupLike" SET createdAt = ?`).run(now); +} + +function restoreFromPreSeeded(sourcePath: string) { + sql.exec(`ATTACH DATABASE '${sourcePath}' AS source`); + + try { + const tables = sql + .prepare( + "SELECT name FROM source.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ) + .all() as Array<{ name: string }>; + + sql.exec("PRAGMA foreign_keys = OFF"); + + for (const { name } of tables) { + sql.exec(`DELETE FROM main."${name}"`); + + // Get non-generated columns for this table (table_xinfo includes hidden column info) + const columns = sql + .prepare(`PRAGMA main.table_xinfo("${name}")`) + .all() as Array<{ name: string; hidden: number }>; + + // hidden = 2 or 3 means virtual/stored generated column + const nonGeneratedCols = columns + .filter((c) => c.hidden === 0) + .map((c) => c.name); + + if (nonGeneratedCols.length > 0) { + const colList = nonGeneratedCols.map((c) => `"${c}"`).join(", "); + sql.exec( + `INSERT INTO main."${name}" (${colList}) SELECT ${colList} FROM source."${name}"`, + ); + } + } + + sql.exec("PRAGMA foreign_keys = ON"); + } finally { + sql.exec("DETACH DATABASE source"); + } +} diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts index 103755c58..15bed5a6d 100644 --- a/app/features/sendouq/core/SendouQ.server.test.ts +++ b/app/features/sendouq/core/SendouQ.server.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { db } from "~/db/sql"; +import { refreshUserSkills } from "~/features/mmr/tiered.server"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import { dbInsertUsers, dbReset } from "~/utils/Test"; import * as SQGroupRepository from "../SQGroupRepository.server"; @@ -359,6 +360,10 @@ describe("SendouQ", () => { }); describe("tier sorting", () => { + beforeEach(() => { + refreshUserSkills(1); + }); + test("sorts full groups by tier when viewer has a tier", async () => { await insertSkill(1, 1000); await insertSkill(2, 500); diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index 07ea38258..9da2d6327 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -1,8 +1,42 @@ -import { expect, type Locator, type Page } from "@playwright/test"; +import { + test as base, + expect, + type Locator, + type Page, +} from "@playwright/test"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import type { SeedVariation } from "~/features/api-private/routes/seed"; import { tournamentBracketsPage } from "./urls"; +const BASE_PORT = 6173; + +type WorkerFixtures = { + workerPort: number; + workerBaseURL: string; +}; + +export const test = base.extend({ + workerPort: [ + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires object destructuring + async ({}, use, workerInfo) => { + const port = BASE_PORT + 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); + }, +}); + +export { expect }; + export async function selectWeapon({ page, name, @@ -65,7 +99,15 @@ export async function selectUser({ /** page.goto that waits for the page to be hydrated before proceeding */ export async function navigate({ page, url }: { page: Page; url: string }) { - await page.goto(url); + // 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); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index f6651cd8a..354a4a4da 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -300,6 +300,8 @@ export const tournamentEditPage = (eventId: number) => export const calendarReportWinnersPage = (eventId: number) => `/calendar/${eventId}/report-winners`; export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`; +export const tournamentTeamsPage = (tournamentId: number) => + `/to/${tournamentId}/teams`; export const tournamentTeamPage = ({ tournamentId, tournamentTeamId, diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 57af7c03d..c5305ecce 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/analyzer.spec.ts b/e2e/analyzer.spec.ts index 49ca21bf3..7fd96add4 100644 --- a/e2e/analyzer.spec.ts +++ b/e2e/analyzer.spec.ts @@ -1,10 +1,11 @@ -import { expect, test } from "@playwright/test"; import { + expect, impersonate, isNotVisible, navigate, seed, selectWeapon, + test, } from "~/utils/playwright"; import { ANALYZER_URL } from "~/utils/urls"; diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index f522360ae..6731466d9 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -1,12 +1,13 @@ -import test, { expect } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, navigate, seed, submit, + test, } from "~/utils/playwright"; import { associationsPage, scrimsPage } from "~/utils/urls"; diff --git a/e2e/badges.spec.ts b/e2e/badges.spec.ts index cd26af515..e1d5ac585 100644 --- a/e2e/badges.spec.ts +++ b/e2e/badges.spec.ts @@ -1,5 +1,11 @@ -import { expect, test } from "@playwright/test"; -import { impersonate, navigate, seed, selectUser } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + selectUser, + test, +} from "~/utils/playwright"; import { badgePage } from "~/utils/urls"; import { NZAP_TEST_ID } from "../app/db/seed/constants"; diff --git a/e2e/ban.spec.ts b/e2e/ban.spec.ts index e46d0e1f9..7dd887854 100644 --- a/e2e/ban.spec.ts +++ b/e2e/ban.spec.ts @@ -1,10 +1,12 @@ -import test, { expect, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, navigate, seed, + test, waitForPOSTResponse, } from "~/utils/playwright"; import { ADMIN_PAGE, SUSPENDED_PAGE } from "~/utils/urls"; diff --git a/e2e/builds.spec.ts b/e2e/builds.spec.ts index 460bfa95f..469cda39e 100644 --- a/e2e/builds.spec.ts +++ b/e2e/builds.spec.ts @@ -1,13 +1,15 @@ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants"; import type { GearType } from "~/db/tables"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, navigate, seed, selectWeapon, submit, + test, } from "~/utils/playwright"; import { BUILDS_PAGE, userBuildsPage, userNewBuildPage } from "~/utils/urls"; diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 930936a05..df65c7acb 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -1,11 +1,12 @@ -import { expect, test } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { + expect, expectIsHydrated, impersonate, isNotVisible, navigate, seed, + test, } from "~/utils/playwright"; import { calendarPage } from "~/utils/urls"; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 000000000..975913f6f --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,131 @@ +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import fs from "node:fs"; +import type { FullConfig } from "@playwright/test"; + +const BASE_PORT = 6173; +const WORKER_COUNT = Number(process.env.E2E_WORKERS) || 4; +const DEBUG = process.env.E2E_DEBUG === "true"; +const SERVER_PROCESSES: ChildProcess[] = []; + +declare global { + var __E2E_SERVERS__: ChildProcess[]; +} + +function killProcessOnPort(port: number): void { + try { + // Try to find and kill any process on this port (macOS/Linux) + execSync(`lsof -ti :${port} | xargs -r kill -9 2>/dev/null || true`, { + stdio: "pipe", + }); + } catch { + // Ignore errors - port might already be free + } +} + +async function waitForServer(port: number, timeout = 120000): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const response = await fetch(`http://localhost:${port}/`); + if (response.ok || response.status === 404) { + // 404 is fine - server is up, just no route at / + return; + } + } catch { + // Server not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Server on port ${port} did not start within ${timeout}ms`); +} + +async function globalSetup(_config: FullConfig) { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`\nStarting e2e test setup with ${WORKER_COUNT} workers...`); + + // Build the app once with E2E test flag so VITE_E2E_TEST_RUN is embedded + // Use port 6173 as the base - tests will rewrite URLs as needed + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("Building the application..."); + execSync("npm run build", { + stdio: "inherit", + env: { + ...process.env, + VITE_E2E_TEST_RUN: "true", + VITE_SITE_DOMAIN: `http://localhost:${BASE_PORT}`, + }, + }); + + // Prepare databases and start servers for each worker + const serverPromises: Promise[] = []; + + // Kill any existing processes on our ports before starting + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("Cleaning up any existing processes on e2e ports..."); + for (let i = 0; i < WORKER_COUNT; i++) { + killProcessOnPort(BASE_PORT + i); + } + // Wait briefly for ports to be released + await new Promise((resolve) => setTimeout(resolve, 500)); + + for (let i = 0; i < WORKER_COUNT; i++) { + const port = BASE_PORT + i; + const dbPath = `db-test-e2e-${i}.sqlite3`; + + // Ensure database exists with migrations + if (!fs.existsSync(dbPath)) { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`Setting up database for worker ${i}: ${dbPath}`); + execSync(`DB_PATH=${dbPath} npm run migrate up`, { stdio: "inherit" }); + } + + // Start server + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`Starting server for worker ${i} on port ${port}...`); + const serverProcess = spawn("npm", ["start"], { + env: { + ...process.env, + DB_PATH: dbPath, + PORT: String(port), + DISCORD_CLIENT_ID: "123", + DISCORD_CLIENT_SECRET: "secret", + SESSION_SECRET: "secret", + VITE_SITE_DOMAIN: `http://localhost:${port}`, + VITE_E2E_TEST_RUN: "true", + }, + detached: false, + }); + + SERVER_PROCESSES.push(serverProcess); + + if (DEBUG) { + serverProcess.stdout?.on("data", (data) => { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`[Worker ${i}] ${data.toString()}`); + }); + + serverProcess.stderr?.on("data", (data) => { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.error(`[Worker ${i} ERROR] ${data.toString()}`); + }); + } + + serverPromises.push( + waitForServer(port).then(() => { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`Server for worker ${i} is ready on port ${port}`); + }), + ); + } + + // Wait for all servers to be ready + await Promise.all(serverPromises); + + // Store server processes globally for teardown + global.__E2E_SERVERS__ = SERVER_PROCESSES; + + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("\nAll servers started successfully!\n"); +} + +export default globalSetup; diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 000000000..a2350f37e --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,26 @@ +import type { FullConfig } from "@playwright/test"; + +declare global { + var __E2E_SERVERS__: import("node:child_process").ChildProcess[]; +} + +async function globalTeardown(_config: FullConfig) { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("\nStopping e2e test servers..."); + + const servers = global.__E2E_SERVERS__ || []; + + for (const server of servers) { + if (server && !server.killed) { + server.kill("SIGTERM"); + } + } + + // Give processes a moment to clean up + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("All servers stopped.\n"); +} + +export default globalTeardown; diff --git a/e2e/lfg.spec.ts b/e2e/lfg.spec.ts index 57b54da21..8d71a40bd 100644 --- a/e2e/lfg.spec.ts +++ b/e2e/lfg.spec.ts @@ -1,5 +1,11 @@ -import test, { expect } from "@playwright/test"; -import { impersonate, navigate, seed, submit } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + submit, + test, +} from "~/utils/playwright"; import { LFG_PAGE } from "~/utils/urls"; test.describe("LFG", () => { diff --git a/e2e/object-damage-calculator.spec.ts b/e2e/object-damage-calculator.spec.ts index 4d3bb1f04..726560c73 100644 --- a/e2e/object-damage-calculator.spec.ts +++ b/e2e/object-damage-calculator.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from "@playwright/test"; -import { navigate, selectWeapon } from "~/utils/playwright"; +import { expect, navigate, selectWeapon, test } from "~/utils/playwright"; import { OBJECT_DAMAGE_CALCULATOR_URL } from "~/utils/urls"; test.describe("Object Damage Calculator", () => { diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 592776ca4..a35ef83c1 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -1,13 +1,14 @@ -import test, { expect } from "@playwright/test"; -import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { NZAP_TEST_ID, ORG_ADMIN_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, navigate, seed, selectUser, submit, + test, waitForPOSTResponse, } from "~/utils/playwright"; import { @@ -154,11 +155,9 @@ test.describe("Tournament Organization", () => { test("allows member of established org to create tournament", async ({ page, }) => { - const ORG_ADMIN_ID = 3; // 3 = org admin, but not site admin - await seed(page); - await impersonate(page, ORG_ADMIN_ID); + await impersonate(page, ORG_ADMIN_TEST_ID); await navigate({ page, url: TOURNAMENT_NEW_PAGE, @@ -178,7 +177,7 @@ test.describe("Tournament Organization", () => { page.getByTestId("is-established-switch").click(), ); - await impersonate(page, ORG_ADMIN_ID); + await impersonate(page, ORG_ADMIN_TEST_ID); await navigate({ page, url: TOURNAMENT_NEW_PAGE, diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 0b2edd240..187250e67 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,13 +1,14 @@ -import test, { expect } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, navigate, seed, selectUser, setDateTime, submit, + test, } from "~/utils/playwright"; import { newScrimPostPage, scrimsPage } from "~/utils/urls"; @@ -56,8 +57,6 @@ test.describe("Scrims", () => { test("requests an existing scrim post & cancels the request", async ({ page, }) => { - const INITIAL_AVAILABLE_TO_REQUEST_COUNT = 15; - await seed(page); await impersonate(page, ADMIN_ID); await navigate({ @@ -68,13 +67,16 @@ test.describe("Scrims", () => { const requestScrimButtonLocator = page.getByTestId("request-scrim-button"); await page.getByTestId("available-scrims-tab").click(); + + await expect(requestScrimButtonLocator.first()).toBeVisible(); + + const initialCount = await requestScrimButtonLocator.count(); + await requestScrimButtonLocator.first().click(); await submit(page); - await expect(requestScrimButtonLocator).toHaveCount( - INITIAL_AVAILABLE_TO_REQUEST_COUNT - 1, - ); + await expect(requestScrimButtonLocator).toHaveCount(initialCount - 1); const togglePendingRequestsButton = page.getByTestId( "toggle-pending-requests-button", @@ -89,9 +91,7 @@ test.describe("Scrims", () => { }); await cancelButton.click(); - await expect(requestScrimButtonLocator).toHaveCount( - INITIAL_AVAILABLE_TO_REQUEST_COUNT, - ); + await expect(requestScrimButtonLocator).toHaveCount(initialCount); }); test("accepts a request", async ({ page }) => { diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 new file mode 100644 index 000000000..0367d9bd9 Binary files /dev/null and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 new file mode 100644 index 000000000..2c0111c4a Binary files /dev/null and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 new file mode 100644 index 000000000..ef6f731c8 Binary files /dev/null and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 new file mode 100644 index 000000000..fb153a53e Binary files /dev/null and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 new file mode 100644 index 000000000..d2f61d7b0 Binary files /dev/null and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 new file mode 100644 index 000000000..e80c78b4c Binary files /dev/null and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 new file mode 100644 index 000000000..aa251a60d Binary files /dev/null and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 20dde1d50..7668827d4 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -1,7 +1,13 @@ -import test, { expect } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { impersonate, navigate, seed, submit } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + submit, + test, +} from "~/utils/playwright"; import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 0a9f96dad..a471a0d11 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,5 +1,11 @@ -import test, { expect } from "@playwright/test"; -import { impersonate, navigate, seed } from "~/utils/playwright"; +import { + expect, + impersonate, + navigate, + seed, + test, + waitForPOSTResponse, +} from "~/utils/playwright"; import { SETTINGS_PAGE } from "~/utils/urls"; test.describe("Settings", () => { @@ -22,9 +28,9 @@ test.describe("Settings", () => { url: SETTINGS_PAGE, }); - await page - .getByTestId("UPDATE_DISABLE_BUILD_ABILITY_SORTING-switch") - .click(); + await waitForPOSTResponse(page, () => + page.getByTestId("UPDATE_DISABLE_BUILD_ABILITY_SORTING-switch").click(), + ); await navigate({ page, diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 577fcb53d..8aee80026 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -1,13 +1,14 @@ -import { expect, test } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, modalClickConfirmButton, navigate, seed, submit, + test, } from "~/utils/playwright"; import { editTeamPage, diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index cd9220435..7e824ac6f 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -1,5 +1,5 @@ -import { expect, type Locator, type Page, test } from "@playwright/test"; -import { navigate } from "~/utils/playwright"; +import type { Locator, Page } from "@playwright/test"; +import { expect, navigate, test } from "~/utils/playwright"; import { TIER_LIST_MAKER_URL } from "~/utils/urls"; test.describe("Tier List Maker", () => { diff --git a/e2e/top-search.spec.ts b/e2e/top-search.spec.ts index e72446088..d672d5b56 100644 --- a/e2e/top-search.spec.ts +++ b/e2e/top-search.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from "@playwright/test"; -import { navigate, seed } from "~/utils/playwright"; +import { expect, navigate, seed, test } from "~/utils/playwright"; import { topSearchPage, userPage } from "~/utils/urls"; test.describe("Top search", () => { diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 581fa7f7d..72c1ce6a9 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -1,7 +1,8 @@ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, navigate, @@ -9,6 +10,7 @@ import { selectUser, startBracket, submit, + test, } from "~/utils/playwright"; import { NOTIFICATIONS_URL, @@ -17,6 +19,7 @@ import { tournamentBracketsPage, tournamentMatchPage, tournamentPage, + tournamentTeamsPage, userResultsPage, } from "~/utils/urls"; @@ -530,7 +533,10 @@ test.describe("Tournament bracket", () => { }); await submit(page); - await page.getByTestId("teams-tab").click(); + await navigate({ + page, + url: tournamentTeamsPage(tournamentId), + }); await expect( page.getByTestId("team-member-name").getByText("Sendou"), @@ -966,6 +972,13 @@ test.describe("Tournament bracket", () => { await submit(page, "confirm-finalize-bracket-button"); + await navigate({ + page, + url: tournamentBracketsPage({ tournamentId }), + }); + + await page.getByRole("button", { name: "Great White" }).click(); + await expect(page.getByTestId("prepared-maps-check-icon")).toBeVisible(); // we did not prepare maps for group stage diff --git a/e2e/tournament-staff.spec.ts b/e2e/tournament-staff.spec.ts index b15af6a9d..7e9ef4a41 100644 --- a/e2e/tournament-staff.spec.ts +++ b/e2e/tournament-staff.spec.ts @@ -1,7 +1,7 @@ -import test, { expect } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, modalClickConfirmButton, @@ -10,6 +10,7 @@ import { selectUser, startBracket, submit, + test, } from "~/utils/playwright"; import { tournamentAdminPage, diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index 1dd380c93..47939062d 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -1,19 +1,20 @@ -import { expect, test } from "@playwright/test"; -// import { NZAP_TEST_ID } from "~/db/seed/constants"; -// import { ADMIN_ID } from "~/features/admin/admin-constants"; import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { StageId } from "~/modules/in-game-lists/types"; -// import invariant from "~/utils/invariant"; import { + expect, impersonate, isNotVisible, navigate, seed, - // selectUser, submit, + test, } from "~/utils/playwright"; -import { tournamentBracketsPage, tournamentPage } from "~/utils/urls"; +import { + tournamentBracketsPage, + tournamentPage, + tournamentTeamsPage, +} from "~/utils/urls"; // TODO: restore operates admin controls after single fetch tested in prod @@ -263,7 +264,10 @@ test.describe("Tournament", () => { await submit(page); - await page.getByTestId("teams-tab").click(); + await navigate({ + page, + url: tournamentTeamsPage(1), + }); await expect(page.getByTestId("team-name").first()).not.toHaveText( "Chimera", ); diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts index 952223c03..bf6d126c2 100644 --- a/e2e/user-page.spec.ts +++ b/e2e/user-page.spec.ts @@ -1,13 +1,15 @@ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; import { + expect, impersonate, isNotVisible, navigate, seed, selectWeapon, submit, + test, } from "~/utils/playwright"; import { userEditProfilePage, userPage } from "~/utils/urls"; diff --git a/e2e/vods.spec.ts b/e2e/vods.spec.ts index c8eefdac1..362bbf8a4 100644 --- a/e2e/vods.spec.ts +++ b/e2e/vods.spec.ts @@ -1,5 +1,6 @@ -import test, { expect, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { + expect, impersonate, isNotVisible, navigate, @@ -8,6 +9,7 @@ import { selectUser, selectWeapon, submit, + test, } from "~/utils/playwright"; import { newVodPage, VODS_PAGE, vodVideoPage } from "~/utils/urls"; diff --git a/package.json b/package.json index ac61e5f9e..4f367c188 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:unit": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only run", "test:e2e": "npx playwright test", "test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1", + "test:e2e:generate-seeds": "cross-env DB_PATH=db-test.sqlite3 npx vite-node scripts/generate-e2e-seed-dbs.ts", "checks": "npm run biome:fix && npm run test:unit && npm run check-translation-jsons && npm run typecheck && npm run knip", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "i18n:sync": "i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && npm run biome:fix", diff --git a/playwright.config.ts b/playwright.config.ts index 0be420312..8d927db7b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,7 @@ import type { PlaywrightTestConfig } from "@playwright/test"; import { devices } from "@playwright/test"; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); +const WORKER_COUNT = Number(process.env.E2E_WORKERS) || 4; /** * See https://playwright.dev/docs/test-configuration. @@ -25,16 +21,19 @@ const config: PlaywrightTestConfig = { fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - retries: 2, - /* Opt out of parallel tests. */ - workers: 1, + retries: 0, + /* Number of parallel workers */ + workers: WORKER_COUNT, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "list", + /* Global setup and teardown for managing multiple server instances */ + globalSetup: "./e2e/global-setup.ts", + globalTeardown: "./e2e/global-teardown.ts", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 0, - /* Base URL to use in actions like `await page.goto('/')`. */ + /* Base URL will be set per-worker by the fixture */ baseURL: "http://localhost:6173", trace: "retain-on-failure", @@ -97,24 +96,6 @@ const config: PlaywrightTestConfig = { /* Folder for test artifacts such as screenshots, videos, traces, etc. */ // outputDir: 'test-results/', - /* Run your local dev server before starting the tests */ - webServer: { - // uncomment to see server logs output - // stdout: "pipe", - env: { - DB_PATH: "db-test-e2e.sqlite3", - DISCORD_CLIENT_ID: "123", - DISCORD_CLIENT_SECRET: "secret", - SESSION_SECRET: "secret", - PORT: "6173", - VITE_SITE_DOMAIN: "http://localhost:6173", - VITE_E2E_TEST_RUN: "true", - }, - command: "npm run build && npm start", - port: 6173, - reuseExistingServer: false, - timeout: 60_000 * 2, // 2 minutes - }, build: { external: ["**/*.json"], }, diff --git a/scripts/generate-e2e-seed-dbs.ts b/scripts/generate-e2e-seed-dbs.ts new file mode 100644 index 000000000..574b3667a --- /dev/null +++ b/scripts/generate-e2e-seed-dbs.ts @@ -0,0 +1,55 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { SEED_VARIATIONS } from "../app/features/api-private/constants"; + +const E2E_SEEDS_DIR = "e2e/seeds"; +const BASE_TEST_DB = "db-test.sqlite3"; + +async function generatePreSeededDatabases() { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log("Generating pre-seeded databases for e2e tests...\n"); + + if (!fs.existsSync(E2E_SEEDS_DIR)) { + fs.mkdirSync(E2E_SEEDS_DIR, { recursive: true }); + } + + const baseDbPath = path.resolve(BASE_TEST_DB); + if (!fs.existsSync(baseDbPath)) { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.error( + `Base test database not found: ${baseDbPath}. Run migrations first.`, + ); + process.exit(1); + } + + for (const variation of SEED_VARIATIONS) { + const outputPath = path.join(E2E_SEEDS_DIR, `db-seed-${variation}.sqlite3`); + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`Generating ${variation}...`); + + fs.copyFileSync(baseDbPath, outputPath); + + execSync( + `npx vite-node scripts/seed-single-variation.ts -- ${variation} ${outputPath}`, + { stdio: "inherit" }, + ); + + const db = new Database(outputPath); + db.pragma("wal_checkpoint(TRUNCATE)"); + db.close(); + + const stats = fs.statSync(outputPath); + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log( + ` ✓ ${variation}: ${(stats.size / 1024 / 1024).toFixed(2)} MB\n`, + ); + } + + // biome-ignore lint/suspicious/noConsole: CLI script output + console.log(`Done! Pre-seeded databases saved to ${E2E_SEEDS_DIR}/`); +} + +// biome-ignore lint/suspicious/noConsole: CLI script output +generatePreSeededDatabases().catch(console.error); diff --git a/scripts/seed-single-variation.ts b/scripts/seed-single-variation.ts new file mode 100644 index 000000000..881b6d957 --- /dev/null +++ b/scripts/seed-single-variation.ts @@ -0,0 +1,15 @@ +import type { SeedVariation } from "~/features/api-private/routes/seed"; + +const variation = process.argv[2] as SeedVariation; +const dbPath = process.argv[3]; + +if (!variation || !dbPath) { + // biome-ignore lint/suspicious/noConsole: CLI script output + console.error("Usage: seed-single-variation.ts "); + process.exit(1); +} + +process.env.DB_PATH = dbPath; + +const { seed } = await import("../app/db/seed/index"); +await seed(variation === "DEFAULT" ? null : variation);