mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Fix test infrastructure and stabilize e2e tests (#2836)
This commit is contained in:
parent
c3cf13cf15
commit
e6d0a8189f
115
.claude/skills/e2e/SKILL.md
Normal file
115
.claude/skills/e2e/SKILL.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
---
|
||||||
|
name: e2e
|
||||||
|
description: Run, debug, and manage Playwright e2e tests. Use when running e2e tests, debugging test failures, regenerating seed databases, or investigating test infrastructure issues.
|
||||||
|
---
|
||||||
|
|
||||||
|
# E2E Test Runner
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
- Tests live in `e2e/*.spec.ts`, config in `playwright.config.ts`
|
||||||
|
- Global setup (`e2e/global-setup.ts`) builds the app, creates per-worker databases, and starts one server per worker
|
||||||
|
- Port calculation: `E2E_BASE_PORT = PORT (from .env) + 500`. Default PORT is typically 4001, so base port = 4501. Workers use ports base+0 through base+3
|
||||||
|
- Worker databases: `db-test-e2e-0.sqlite3` through `db-test-e2e-3.sqlite3` in the project root
|
||||||
|
- Seed databases (pre-seeded snapshots): `e2e/seeds/db-seed-*.sqlite3`
|
||||||
|
- MinIO (S3-compatible storage) is started via Docker Compose if not already running
|
||||||
|
|
||||||
|
## Pre-flight checks (run before every test execution)
|
||||||
|
|
||||||
|
Before running tests, check for these common issues:
|
||||||
|
|
||||||
|
1. **Stale worker databases** — Files matching `db-test-e2e-*.sqlite3` in the project root can cause "table already exists" migration errors if the schema has changed since they were created. Run `npm run test:e2e:generate-seeds` to regenerate these from the seed databases.
|
||||||
|
|
||||||
|
2. **Port conflicts** — Check if anything is already listening on the e2e ports (base port through base+3):
|
||||||
|
```
|
||||||
|
lsof -i :4501-4504 2>/dev/null
|
||||||
|
```
|
||||||
|
If ports are occupied by leftover e2e servers, kill them. If occupied by something else, warn the user.
|
||||||
|
|
||||||
|
3. **Seed databases exist** — Verify `e2e/seeds/` contains the expected seed files. If missing, run `npm run test:e2e:generate-seeds`.
|
||||||
|
|
||||||
|
4. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
### Run all tests
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run a specific test file
|
||||||
|
```bash
|
||||||
|
npx playwright test e2e/<name>.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flaky detection (repeats each test 10 times, stops on first failure)
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:flaky-detect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regenerate seed databases (after schema/migration changes)
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:generate-seeds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging failures
|
||||||
|
|
||||||
|
Follow this funnel when tests fail:
|
||||||
|
|
||||||
|
### Step 1: Read the error output
|
||||||
|
- Look for the actual assertion or timeout that failed
|
||||||
|
- Check if it's an infrastructure error (server didn't start, migration failed) vs. a test logic error
|
||||||
|
|
||||||
|
### Step 2: Check infrastructure issues
|
||||||
|
Common infrastructure errors and fixes:
|
||||||
|
- **"table already exists"** → Stale worker DBs. Run `rm -f db-test-e2e-*.sqlite3`
|
||||||
|
- **"Server on port X did not start within timeout"** → Port conflict or app build error. Check ports with `lsof -i :<port>` and check for build errors
|
||||||
|
- **"MinIO failed to start"** → Docker not running or compose issue. Check `docker info`
|
||||||
|
- **Seed-related errors** → Run `npm run test:e2e:generate-seeds`
|
||||||
|
|
||||||
|
### Step 3: Reduce to single debug worker
|
||||||
|
If the error is unclear, re-run with debug output and a single worker to see server logs:
|
||||||
|
```bash
|
||||||
|
E2E_DEBUG=true E2E_WORKERS=1 npx playwright test e2e/<failing-test>.spec.ts
|
||||||
|
```
|
||||||
|
This shows stdout/stderr from the test server, which is hidden by default.
|
||||||
|
|
||||||
|
### Step 4: Examine trace artifacts
|
||||||
|
Playwright is configured with `trace: "retain-on-failure"`. After a failure, view the trace:
|
||||||
|
```bash
|
||||||
|
npx playwright show-trace test-results/<test-folder>/trace.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test pattern reference
|
||||||
|
|
||||||
|
Every test follows this pattern — use these imports from `~/utils/playwright`, NOT raw Playwright APIs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
|
||||||
|
|
||||||
|
test.describe("Feature", () => {
|
||||||
|
test("does something", async ({ page }) => {
|
||||||
|
await seed(page); // Reset DB to a known seed state
|
||||||
|
await impersonate(page, USER_ID); // Log in as a specific user (default: admin)
|
||||||
|
await navigate({ page, url: "..." });// Navigate (waits for hydration)
|
||||||
|
// ... interact with the page ...
|
||||||
|
await submit(page); // Submit a form (waits for POST response)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Use `navigate()` instead of `page.goto()` — it waits for hydration
|
||||||
|
- Use `submit()` instead of clicking submit buttons directly — it waits for the POST response
|
||||||
|
- Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS
|
||||||
|
- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID)
|
||||||
|
- Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead
|
||||||
|
- Import `test` from `~/utils/playwright` (not from `@playwright/test`) — it includes worker port fixtures
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `E2E_WORKERS` | Number of parallel workers | 4 |
|
||||||
|
| `E2E_DEBUG` | Show server stdout/stderr when "true" | unset |
|
||||||
|
| `PORT` | Base port for dev server (e2e adds 500) | 5173 |
|
||||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -43,4 +43,6 @@ jobs:
|
||||||
- name: Check homemade badges
|
- name: Check homemade badges
|
||||||
run: npm run check-homemade-badges
|
run: npm run check-homemade-badges
|
||||||
- name: Check articles
|
- name: Check articles
|
||||||
run: npm run check-articles
|
run: npm run check-articles
|
||||||
|
- name: Check test DB migrations
|
||||||
|
run: npm run check-test-db-migrations
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,6 @@
|
||||||
- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran)
|
- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran)
|
||||||
- `db-prod.sqlite3` is a copy of the production environment db which can be freely experimented with
|
- `db-prod.sqlite3` is a copy of the production environment db which can be freely experimented with
|
||||||
|
|
||||||
## E2E testing
|
|
||||||
|
|
||||||
- library used for E2E testing is Playwright
|
|
||||||
- `page.goto` is forbidden, use the `navigate` function to do a page navigation
|
|
||||||
- to submit a form you use the `submit` function
|
|
||||||
- `page.waitForTimeout` should be avoided
|
|
||||||
|
|
||||||
## Unit testing
|
## Unit testing
|
||||||
|
|
||||||
- library used for unit testing is Vitest
|
- library used for unit testing is Vitest
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,7 @@ describe("SendouQ", () => {
|
||||||
|
|
||||||
describe("skill-based sorting", () => {
|
describe("skill-based sorting", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
refreshUserSkills(1);
|
||||||
await dbInsertUsers(10);
|
await dbInsertUsers(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type { SeedVariation } from "~/features/api-private/routes/seed";
|
||||||
import { tournamentBracketsPage } from "./urls";
|
import { tournamentBracketsPage } from "./urls";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 1000;
|
export const E2E_BASE_PORT = Number(process.env.PORT || 5173) + 500;
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
workerPort: number;
|
workerPort: number;
|
||||||
|
|
|
||||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -127,6 +127,7 @@ test.describe("Builds", () => {
|
||||||
await page.getByLabel("Tower Control").click();
|
await page.getByLabel("Tower Control").click();
|
||||||
await expect(page.getByTestId("build-mode-TC")).toHaveCount(24);
|
await expect(page.getByTestId("build-mode-TC")).toHaveCount(24);
|
||||||
await page.getByTestId("delete-filter-button").click();
|
await page.getByTestId("delete-filter-button").click();
|
||||||
|
await expect(page.getByTestId("build-card").first()).toBeVisible();
|
||||||
|
|
||||||
//
|
//
|
||||||
// date filter
|
// date filter
|
||||||
|
|
|
||||||
|
|
@ -196,17 +196,17 @@ test.describe("Scrims", () => {
|
||||||
await selectUser({
|
await selectUser({
|
||||||
labelName: "User 2",
|
labelName: "User 2",
|
||||||
page,
|
page,
|
||||||
userName: "a",
|
userName: "5",
|
||||||
});
|
});
|
||||||
await selectUser({
|
await selectUser({
|
||||||
labelName: "User 3",
|
labelName: "User 3",
|
||||||
page,
|
page,
|
||||||
userName: "b",
|
userName: "6",
|
||||||
});
|
});
|
||||||
await selectUser({
|
await selectUser({
|
||||||
labelName: "User 4",
|
labelName: "User 4",
|
||||||
page,
|
page,
|
||||||
userName: "c",
|
userName: "7",
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByLabel("Start time").selectOption({ index: 1 });
|
await page.getByLabel("Start time").selectOption({ index: 1 });
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -26,8 +26,9 @@
|
||||||
"test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only",
|
"test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only",
|
||||||
"test:e2e": "npx playwright test",
|
"test:e2e": "npx playwright test",
|
||||||
"test:e2e:flaky-detect": "npx playwright test --repeat-each=10 --max-failures=1",
|
"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",
|
"test:e2e:generate-seeds": "cross-env DB_PATH=db-test.sqlite3 npm run migrate up && 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:browser && npm run check-translation-jsons && npm run typecheck && npm run knip",
|
"check-test-db-migrations": "node --experimental-strip-types scripts/check-test-db-migrations.ts",
|
||||||
|
"checks": "npm run biome:fix && npm run test:unit:browser && npm run check-translation-jsons && npm run typecheck && npm run knip && npm run check-test-db-migrations",
|
||||||
"setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts",
|
"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",
|
"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",
|
||||||
"knip": "knip"
|
"knip": "knip"
|
||||||
|
|
|
||||||
65
scripts/check-test-db-migrations.ts
Normal file
65
scripts/check-test-db-migrations.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/** biome-ignore-all lint/suspicious/noConsole: Biome v2 migration */
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const ROOT_DIR = path.join(__dirname, "..");
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = path.join(ROOT_DIR, "migrations");
|
||||||
|
const DB_FILES = [
|
||||||
|
path.join(ROOT_DIR, "db-test.sqlite3"),
|
||||||
|
...fs
|
||||||
|
.readdirSync(path.join(ROOT_DIR, "e2e", "seeds"))
|
||||||
|
.filter((f) => f.startsWith("db-seed-") && f.endsWith(".sqlite3"))
|
||||||
|
.map((f) => path.join(ROOT_DIR, "e2e", "seeds", f)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const migrationFilesOnDisk = fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".js"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
for (const dbPath of DB_FILES) {
|
||||||
|
const relativePath = path.relative(ROOT_DIR, dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
console.warn(`Warning: ${relativePath} does not exist, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT name FROM migrations ORDER BY id ASC")
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const migrationsInDb = new Set(rows.map((r) => r.name));
|
||||||
|
const missingMigrations = migrationFilesOnDisk.filter(
|
||||||
|
(name) => !migrationsInDb.has(name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingMigrations.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(
|
||||||
|
`\n${relativePath} is missing ${missingMigrations.length} migration(s):`,
|
||||||
|
);
|
||||||
|
for (const name of missingMigrations) {
|
||||||
|
console.error(` - ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(
|
||||||
|
"\nRun `npm run test:e2e:generate-seeds` to regenerate test databases.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("All test databases have the latest migrations.");
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,20 @@ import { SEED_VARIATIONS } from "../app/features/api-private/constants";
|
||||||
const E2E_SEEDS_DIR = "e2e/seeds";
|
const E2E_SEEDS_DIR = "e2e/seeds";
|
||||||
const BASE_TEST_DB = "db-test.sqlite3";
|
const BASE_TEST_DB = "db-test.sqlite3";
|
||||||
|
|
||||||
|
const E2E_WORKER_DB_PATTERN = /^db-test-e2e-\d+\.sqlite3$/;
|
||||||
|
|
||||||
async function generatePreSeededDatabases() {
|
async function generatePreSeededDatabases() {
|
||||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||||
console.log("Generating pre-seeded databases for e2e tests...\n");
|
console.log("Generating pre-seeded databases for e2e tests...\n");
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(".")) {
|
||||||
|
if (E2E_WORKER_DB_PATTERN.test(file)) {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||||
|
console.log(`Deleted stale worker db: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(E2E_SEEDS_DIR)) {
|
if (!fs.existsSync(E2E_SEEDS_DIR)) {
|
||||||
fs.mkdirSync(E2E_SEEDS_DIR, { recursive: true });
|
fs.mkdirSync(E2E_SEEDS_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user