From b3de79cee8348ede255472832945cc6f3100d15d Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 11 Oct 2025 11:14:33 +0300
Subject: [PATCH] E2E tests against built site + add to pipeline (#2563)
---
.github/workflows/e2e-tests.yml | 42 +++++++++++++++++++
AGENTS.md | 2 +
app/features/admin/core/dev-controls.ts | 5 +++
app/features/admin/loaders/admin.server.ts | 4 +-
app/features/admin/routes/admin.tsx | 7 ++--
app/features/api-private/routes/seed.ts | 3 +-
app/features/auth/core/routes.server.ts | 3 +-
app/features/auth/core/session.server.ts | 8 +++-
app/features/chat/ChatSystemMessage.server.ts | 22 +++++++---
.../notifications/core/notify.server.ts | 3 +-
app/features/sendouq/QRepository.server.ts | 3 +-
app/features/theme/core/session.server.ts | 4 +-
.../tournament/core/streams.server.ts | 6 ++-
app/features/user-search/loaders/u.server.ts | 3 +-
app/modules/permissions/hooks.ts | 7 +++-
app/root.tsx | 3 +-
app/utils/e2e.ts | 1 +
app/utils/playwright.ts | 2 +-
app/utils/urls-img.ts | 7 +++-
biome.json | 3 +-
e2e/associations.spec.ts | 2 +-
e2e/tournament-bracket.spec.ts | 2 +-
e2e/user-page.spec.ts | 2 +-
playwright.config.ts | 18 ++++++--
24 files changed, 128 insertions(+), 34 deletions(-)
create mode 100644 .github/workflows/e2e-tests.yml
create mode 100644 app/features/admin/core/dev-controls.ts
create mode 100644 app/utils/e2e.ts
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 000000000..5d698b22c
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,42 @@
+name: E2E Tests
+
+on:
+ pull_request:
+ paths-ignore:
+ - 'content/**'
+ - 'public/**'
+ - 'locales/**'
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - 'content/**'
+ - 'public/**'
+ - 'locales/**'
+
+jobs:
+ e2e:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps
+
+ - name: Run E2E tests
+ run: npm run test:e2e
+
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
diff --git a/AGENTS.md b/AGENTS.md
index 09d8a455e..5eba870e3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,8 @@
- `npm run typecheck` runs TypeScript type checking
- `npm run biome:fix` runs Biome code formatter and linter
- `npm run test:unit` runs all unit tests
+- `npm run test:e2e` runs all e2e tests
+- `npm run test:e2e:flaky-detect` runs all e2e tests and repeats each 10 times
- `npm run i18n:sync` syncs translation jsons with English and should always be run after adding new text to an English translation file
## Typescript
diff --git a/app/features/admin/core/dev-controls.ts b/app/features/admin/core/dev-controls.ts
new file mode 100644
index 000000000..0cc81eee3
--- /dev/null
+++ b/app/features/admin/core/dev-controls.ts
@@ -0,0 +1,5 @@
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
+
+/** Should the user be able to access dev controls? Seeding & user impersonation without auth. */
+export const DANGEROUS_CAN_ACCESS_DEV_CONTROLS =
+ process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN;
diff --git a/app/features/admin/loaders/admin.server.ts b/app/features/admin/loaders/admin.server.ts
index b9067d3e7..7a3b14b68 100644
--- a/app/features/admin/loaders/admin.server.ts
+++ b/app/features/admin/loaders/admin.server.ts
@@ -4,10 +4,10 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { adminActionSearchParamsSchema } from "../admin-schemas";
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
export const loader = async ({ request }: LoaderFunctionArgs) => {
- // allow unauthorized access in development mode to access impersonation controls
- if (process.env.NODE_ENV === "production") {
+ if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = await requireUser(request);
requireRole(user, "STAFF");
}
diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx
index 498aecfdb..e81b68a00 100644
--- a/app/features/admin/routes/admin.tsx
+++ b/app/features/admin/routes/admin.tsx
@@ -35,6 +35,7 @@ import {
userPage,
} from "~/utils/urls";
import { action } from "../actions/admin.server";
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
import { loader } from "../loaders/admin.server";
export { loader, action };
@@ -111,10 +112,8 @@ function AdminActions() {
return (
- {process.env.NODE_ENV !== "production" && }
- {process.env.NODE_ENV !== "production" || isAdmin ? (
-
- ) : null}
+ {DANGEROUS_CAN_ACCESS_DEV_CONTROLS && }
+ {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? : null}
{isStaff ? : null}
{isStaff ? : null}
diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts
index 749c40d50..5929273bc 100644
--- a/app/features/api-private/routes/seed.ts
+++ b/app/features/api-private/routes/seed.ts
@@ -1,6 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod/v4";
import { seed } from "~/db/seed";
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { SEED_VARIATIONS } from "~/features/api-private/constants";
import { parseRequestPayload } from "~/utils/remix.server";
@@ -13,7 +14,7 @@ export type SeedVariation = NonNullable<
>;
export const action: ActionFunction = async ({ request }) => {
- if (process.env.NODE_ENV === "production") {
+ if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
throw new Response(null, { status: 400 });
}
diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts
index 786bee415..093be1e93 100644
--- a/app/features/auth/core/routes.server.ts
+++ b/app/features/auth/core/routes.server.ts
@@ -2,6 +2,7 @@ import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { isbot } from "isbot";
import { z } from "zod/v4";
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { logger } from "~/utils/logger";
@@ -74,7 +75,7 @@ export const logInAction: ActionFunction = async ({ request }) => {
};
export const impersonateAction: ActionFunction = async ({ request }) => {
- if (process.env.NODE_ENV === "production") {
+ if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = await requireUser(request);
requireRole(user, "ADMIN");
}
diff --git a/app/features/auth/core/session.server.ts b/app/features/auth/core/session.server.ts
index 31b7040c7..f5fbe7e22 100644
--- a/app/features/auth/core/session.server.ts
+++ b/app/features/auth/core/session.server.ts
@@ -1,4 +1,5 @@
import { createCookieSessionStorage } from "@remix-run/node";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
const ONE_YEAR_IN_SECONDS = 31_536_000;
@@ -11,11 +12,14 @@ export const authSessionStorage = createCookieSessionStorage({
name: "__session",
sameSite: "lax",
// need to specify domain so that sub-domains can access it
- domain: process.env.NODE_ENV === "production" ? "sendou.ink" : undefined,
+ domain:
+ process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN
+ ? "sendou.ink"
+ : undefined,
path: "/",
httpOnly: true,
secrets: [process.env.SESSION_SECRET ?? "secret"],
- secure: process.env.NODE_ENV === "production",
+ secure: process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN,
maxAge: ONE_YEAR_IN_SECONDS,
},
});
diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts
index d487febec..432211839 100644
--- a/app/features/chat/ChatSystemMessage.server.ts
+++ b/app/features/chat/ChatSystemMessage.server.ts
@@ -1,4 +1,5 @@
import { nanoid } from "nanoid";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import type { ChatMessage } from "./chat-types";
@@ -13,13 +14,24 @@ interface ChatSystemMessageService {
send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
}
-invariant(
- process.env.SKALOP_SYSTEM_MESSAGE_URL,
- "Missing env var: SKALOP_SYSTEM_MESSAGE_URL",
-);
-invariant(process.env.SKALOP_TOKEN, "Missing env var: SKALOP_TOKEN");
+let systemMessagesDisabled = false;
+
+if (!IS_E2E_TEST_RUN) {
+ invariant(
+ process.env.SKALOP_SYSTEM_MESSAGE_URL,
+ "Missing env var: SKALOP_SYSTEM_MESSAGE_URL",
+ );
+ invariant(process.env.SKALOP_TOKEN, "Missing env var: SKALOP_TOKEN");
+} else if (
+ !process.env.SKALOP_SYSTEM_MESSAGE_URL ||
+ !process.env.SKALOP_TOKEN
+) {
+ systemMessagesDisabled = true;
+}
export const send: ChatSystemMessageService["send"] = (partialMsg) => {
+ if (systemMessagesDisabled) return;
+
const msgArr = Array.isArray(partialMsg) ? partialMsg : [partialMsg];
const fullMessages: ChatMessage[] = msgArr.map((partialMsg) => {
diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts
index 92ba5bdd9..06af6c670 100644
--- a/app/features/notifications/core/notify.server.ts
+++ b/app/features/notifications/core/notify.server.ts
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import pLimit from "p-limit";
import { WebPushError } from "web-push";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import type { NotificationSubscription } from "../../../db/tables";
import i18next from "../../../modules/i18n/i18next.server";
import { logger } from "../../../utils/logger";
@@ -74,7 +75,7 @@ const sentNotifications = new Set();
// deduplicates notifications as a failsafe & anti-abuse mechanism
function isNotificationAlreadySent(notification: Notification) {
// e2e tests should not be affected by this
- if (process.env.NODE_ENV !== "production") {
+ if (IS_E2E_TEST_RUN) {
return false;
}
diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts
index d5ba8bba7..c4384a02c 100644
--- a/app/features/sendouq/QRepository.server.ts
+++ b/app/features/sendouq/QRepository.server.ts
@@ -8,6 +8,7 @@ import type {
UserMapModePreferences,
} from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { userIsBanned } from "../ban/core/banned.server";
@@ -27,7 +28,7 @@ export function mapModePreferencesByGroupId(groupId: number) {
// groups visible for longer to make development easier
const SECONDS_TILL_STALE =
- process.env.NODE_ENV === "development" ? 1_000_000 : 1_800;
+ process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
export async function findLookingGroups({
minGroupSize,
diff --git a/app/features/theme/core/session.server.ts b/app/features/theme/core/session.server.ts
index b4979ee5c..48511e1d2 100644
--- a/app/features/theme/core/session.server.ts
+++ b/app/features/theme/core/session.server.ts
@@ -1,5 +1,5 @@
import { createCookieSessionStorage } from "@remix-run/node";
-
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
import type { Theme } from "./provider";
import { isTheme } from "./provider";
@@ -14,7 +14,7 @@ const sessionSecret = process.env.SESSION_SECRET ?? "secret";
const themeStorage = createCookieSessionStorage({
cookie: {
name: "theme",
- secure: process.env.NODE_ENV === "production",
+ secure: process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN,
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
diff --git a/app/features/tournament/core/streams.server.ts b/app/features/tournament/core/streams.server.ts
index 546fa667a..aa739de48 100644
--- a/app/features/tournament/core/streams.server.ts
+++ b/app/features/tournament/core/streams.server.ts
@@ -1,9 +1,13 @@
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { getStreams } from "~/modules/twitch";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
export async function streamsByTournamentId(tournament: TournamentData["ctx"]) {
// prevent error logs in development
- if (process.env.NODE_ENV === "development" && !process.env.TWITCH_CLIENT_ID) {
+ if (
+ (process.env.NODE_ENV === "development" && !process.env.TWITCH_CLIENT_ID) ||
+ IS_E2E_TEST_RUN
+ ) {
return [];
}
const twitchUsersOfTournament = tournament.teams
diff --git a/app/features/user-search/loaders/u.server.ts b/app/features/user-search/loaders/u.server.ts
index f66301852..f7b31a34c 100644
--- a/app/features/user-search/loaders/u.server.ts
+++ b/app/features/user-search/loaders/u.server.ts
@@ -1,5 +1,6 @@
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import { z } from "zod/v4";
+import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { getUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseSearchParams } from "~/utils/remix.server";
@@ -13,7 +14,7 @@ const searchParamsSchema = z.object({
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
- if (process.env.NODE_ENV === "production") {
+ if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = await getUserId(request);
if (!user) {
return null;
diff --git a/app/modules/permissions/hooks.ts b/app/modules/permissions/hooks.ts
index 5f9c92f8a..9b415c71e 100644
--- a/app/modules/permissions/hooks.ts
+++ b/app/modules/permissions/hooks.ts
@@ -1,5 +1,6 @@
import { useUser } from "~/features/auth/core/user";
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
+import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { isAdmin } from "./utils";
/**
@@ -29,7 +30,11 @@ export function useHasPermission<
if (!user) return false;
// admin can do anything in production but not in development for better testing
- if (process.env.NODE_ENV === "production" && isAdmin(user)) {
+ if (
+ process.env.NODE_ENV === "production" &&
+ !IS_E2E_TEST_RUN &&
+ isAdmin(user)
+ ) {
return true;
}
diff --git a/app/root.tsx b/app/root.tsx
index 7159fb0b6..1daacf3d1 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -48,6 +48,7 @@ import { useIsMounted } from "./hooks/useIsMounted";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
import i18next, { i18nCookie } from "./modules/i18n/i18next.server";
import type { Namespace } from "./modules/i18n/resources.server";
+import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { isRevalidation, metaTags } from "./utils/remix";
import { SUSPENDED_PAGE } from "./utils/urls";
@@ -176,7 +177,7 @@ function Document({
- {process.env.NODE_ENV === "development" && }
+ {IS_E2E_TEST_RUN && }
diff --git a/app/utils/e2e.ts b/app/utils/e2e.ts
new file mode 100644
index 000000000..05cac4ccd
--- /dev/null
+++ b/app/utils/e2e.ts
@@ -0,0 +1 @@
+export const IS_E2E_TEST_RUN = import.meta.env.VITE_E2E_TEST_RUN === "true";
diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts
index 16632d2fd..fbebb7375 100644
--- a/app/utils/playwright.ts
+++ b/app/utils/playwright.ts
@@ -81,7 +81,7 @@ export function modalClickConfirmButton(page: Page) {
}
export async function fetchSendouInk(url: string) {
- const res = await fetch(`http://localhost:5173${url}`);
+ const res = await fetch(`http://localhost:6173${url}`);
if (!res.ok) throw new Error("Response not successful");
return res.json() as T;
diff --git a/app/utils/urls-img.ts b/app/utils/urls-img.ts
index 01f4511bb..ab23ec9a6 100644
--- a/app/utils/urls-img.ts
+++ b/app/utils/urls-img.ts
@@ -1,8 +1,11 @@
// TODO: separating this file from urls.ts is a temporary solution. The reason is that import.meta.env cannot currently be used in files that are consumed by plain Node.js
+import { IS_E2E_TEST_RUN } from "./e2e";
+
const USER_SUBMITTED_IMAGE_ROOT =
- process.env.NODE_ENV === "development" &&
- import.meta.env.VITE_PROD_MODE !== "true"
+ (process.env.NODE_ENV === "development" &&
+ import.meta.env.VITE_PROD_MODE !== "true") ||
+ IS_E2E_TEST_RUN
? "http://127.0.0.1:9000/sendou"
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
diff --git a/biome.json b/biome.json
index 8e72f09df..00de0a5c4 100644
--- a/biome.json
+++ b/biome.json
@@ -6,7 +6,8 @@
"!scripts/dicts/**/*",
"!scripts/output/**/*",
"!app/db/seed/placements.json",
- "!build/**/*"
+ "!build/**/*",
+ "!test-results/**/*"
]
},
"linter": {
diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts
index 90fc56d39..b3d9e4568 100644
--- a/e2e/associations.spec.ts
+++ b/e2e/associations.spec.ts
@@ -63,7 +63,7 @@ test.describe("Associations", () => {
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
- url: inviteLink.replace("https://sendou.ink", "http://localhost:5173"),
+ url: inviteLink.replace("https://sendou.ink", "http://localhost:6173"),
});
await submit(page);
diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts
index 713aa5508..adfc8bcbf 100644
--- a/e2e/tournament-bracket.spec.ts
+++ b/e2e/tournament-bracket.spec.ts
@@ -273,7 +273,7 @@ test.describe("Tournament bracket", () => {
);
const inviteLink = inviteLinkProd.replace(
"https://sendou.ink",
- "http://localhost:5173",
+ "http://localhost:6173",
);
await impersonate(page, NZAP_TEST_ID);
diff --git a/e2e/user-page.spec.ts b/e2e/user-page.spec.ts
index 38d0cc790..952223c03 100644
--- a/e2e/user-page.spec.ts
+++ b/e2e/user-page.spec.ts
@@ -37,7 +37,7 @@ test.describe("User page", () => {
// test changing the big badge
await page.getByAltText("Lobster Crossfire").click();
- expect(page.getByAltText("Lobster Crossfire")).toHaveAttribute(
+ await expect(page.getByAltText("Lobster Crossfire")).toHaveAttribute(
"width",
"125",
);
diff --git a/playwright.config.ts b/playwright.config.ts
index 1871f47a2..2550fb895 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -35,7 +35,7 @@ const config: PlaywrightTestConfig = {
/* 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('/')`. */
- baseURL: "http://localhost:5173",
+ baseURL: "http://localhost:6173",
trace: "retain-on-failure",
@@ -99,9 +99,19 @@ const config: PlaywrightTestConfig = {
/* Run your local dev server before starting the tests */
webServer: {
- command: "npm run dev",
- port: 5173,
- reuseExistingServer: !process.env.CI,
+ 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"],