Añadir configuración y scripts para pruebas E2E, incluyendo base de datos y autenticación

This commit is contained in:
David Arranz 2026-10-04 18:40:58 +02:00
parent 31d8e8ffd6
commit 6098ef840a
16 changed files with 245 additions and 3 deletions

13
.env.e2e.example Normal file
View File

@ -0,0 +1,13 @@
E2E_MYSQL_DATABASE=uecko_e2e
E2E_MYSQL_USER=uecko_e2e
E2E_MYSQL_PASSWORD=change-me-e2e
E2E_MYSQL_ROOT_PASSWORD=change-me-root-e2e
E2E_MYSQL_HOST=127.0.0.1
E2E_MYSQL_PORT=3307
E2E_API_PORT=4101
E2E_CLIENT_PORT=4173
E2E_JWT_SECRET=change-me-e2e-jwt-secret
E2E_JWT_REFRESH_SECRET=change-me-e2e-refresh-secret
E2E_USER_EMAIL=e2e-user@example.test
E2E_USER_PASSWORD=e2e-password
E2E_USER_NAME=E2E User

4
.gitignore vendored
View File

@ -6,6 +6,9 @@ npm-debug.log
/public /public
.env .env
.env.e2e
test-results/
playwright-report/
.passport.js .passport.js
.DS_Store .DS_Store
@ -28,4 +31,3 @@ server/uploads/dealer-logos/
# misc # misc
.DS_Store .DS_Store
*.pem *.pem

19
docker-compose.e2e.yml Normal file
View File

@ -0,0 +1,19 @@
name: uecko-e2e
services:
mariadb-e2e:
image: mariadb:11
environment:
MARIADB_DATABASE: ${E2E_MYSQL_DATABASE:?E2E_MYSQL_DATABASE is required}
MARIADB_USER: ${E2E_MYSQL_USER:?E2E_MYSQL_USER is required}
MARIADB_PASSWORD: ${E2E_MYSQL_PASSWORD:?E2E_MYSQL_PASSWORD is required}
MARIADB_ROOT_PASSWORD: ${E2E_MYSQL_ROOT_PASSWORD:?E2E_MYSQL_ROOT_PASSWORD is required}
ports:
- "127.0.0.1:${E2E_MYSQL_PORT:-3307}:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 2s
timeout: 5s
retries: 30

7
e2e/README.md Normal file
View File

@ -0,0 +1,7 @@
# E2E
Copy `.env.e2e.example` to `.env.e2e`. The DB name must contain `e2e`; scripts reject any other name.
Run `yarn e2e:db:up`, `yarn playwright install chromium`, then `yarn e2e`. Use `yarn e2e:db:down` to discard the isolated DB.
Authentication is performed once by Playwright global setup through the UI, then saved as browser storage state. This is required because `react-secure-storage` encrypts its local storage values.

18
e2e/fixtures/auth.ts Normal file
View File

@ -0,0 +1,18 @@
import { chromium, type FullConfig } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
export default async function globalSetup(config: FullConfig) {
const email = process.env.E2E_USER_EMAIL;
const password = process.env.E2E_USER_PASSWORD;
if (!email || !password) throw new Error("E2E_USER_EMAIL and E2E_USER_PASSWORD are required");
await mkdir("test-results", { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL: config.projects[0].use.baseURL as string });
await page.goto("/login");
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill(password);
await Promise.all([page.waitForURL("**/quotes"), page.locator('button[type="submit"]').click()]);
await page.context().storageState({ path: resolve("test-results/.auth.json") });
await browser.close();
}

1
e2e/fixtures/quotes.ts Normal file
View File

@ -0,0 +1 @@
export const uniqueCustomerReference = () => `E2E-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

View File

@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
import { uniqueCustomerReference } from "../fixtures/quotes";
test("creates, edits and persists a quote line", async ({ page }) => {
await page.goto("/quotes/add");
await page.locator('input[name="customer_reference"]').fill(uniqueCustomerReference());
await page.locator('textarea[name="customer_information"]').fill("E2E Customer");
await Promise.all([page.waitForURL(/\/quotes\/edit\/[\w-]+$/), page.locator('button[type="submit"]').click()]);
await page.getByRole("button", { name: /Añadir fila/i }).click();
await page.locator('textarea[name^="items."][name$=".description"]').last().fill("E2E Line");
await page.locator('input[name^="items."][name$=".quantity"]').last().fill("2");
await page.locator('input[name^="items."][name$=".unit_price"]').last().fill("100");
await Promise.all([
page.waitForResponse((response) => response.request().method() === "PUT" && /\/quotes\//.test(response.url()) && response.ok()),
page.getByRole("button", { name: /^Guardar$/i }).click(),
]);
await page.reload();
await expect(page.locator('textarea[name^="items."][name$=".description"]')).toContainText("E2E Line");
await expect(page.locator('input[name^="items."][name$=".quantity"]')).toHaveValue(/2/);
await expect(page.locator('input[name^="items."][name$=".unit_price"]')).toHaveValue(/100/);
});

16
e2e/scripts/db.mjs Normal file
View File

@ -0,0 +1,16 @@
import { spawnSync } from "node:child_process";
import { assertE2EDatabase, e2eEnv, root } from "./env.mjs";
const action = process.argv[2];
if (!['up', 'down'].includes(action)) throw new Error("Usage: db.mjs up|down");
assertE2EDatabase();
for (const key of ["E2E_MYSQL_USER", "E2E_MYSQL_PASSWORD", "E2E_MYSQL_ROOT_PASSWORD"]) e2eEnv(key);
const args = ["compose", "--env-file", ".env.e2e", "-f", "docker-compose.e2e.yml", action];
if (action === "up") args.push("-d", "--wait");
if (action === "down") args.push("--volumes");
const result = spawnSync("docker", args, { cwd: root, stdio: "inherit" });
if (result.error) {
console.error(`Unable to run Docker for the E2E database: ${result.error.message}`);
}
process.exit(result.status ?? 1);

28
e2e/scripts/env.mjs Normal file
View File

@ -0,0 +1,28 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "../..");
const envFile = resolve(root, ".env.e2e");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split("\n")) {
const match = line.match(/^\s*([A-Z0-9_]+)=(.*)\s*$/);
if (match && process.env[match[1]] === undefined) process.env[match[1]] = match[2];
}
}
export const e2eEnv = (name) => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}. Copy .env.e2e.example to .env.e2e and set it.`);
return value;
};
export const assertE2EDatabase = () => {
const database = e2eEnv("E2E_MYSQL_DATABASE");
if (!database.toLowerCase().includes("e2e")) {
throw new Error(`Refusing E2E operation for non-E2E database: ${database}`);
}
return database;
};
export { root };

View File

@ -0,0 +1,7 @@
import { spawn } from "node:child_process";
import { assertE2EDatabase, e2eEnv, root } from "./env.mjs";
assertE2EDatabase();
for (const key of ["E2E_USER_EMAIL", "E2E_USER_PASSWORD", "E2E_API_PORT", "E2E_CLIENT_PORT"]) e2eEnv(key);
const child = spawn("yarn", ["playwright", "test", ...process.argv.slice(2)], { cwd: root, stdio: "inherit", env: process.env });
child.on("exit", (code) => process.exit(code ?? 1));

View File

@ -0,0 +1,10 @@
import { spawn } from "node:child_process";
import { e2eEnv, root } from "./env.mjs";
const port = e2eEnv("E2E_CLIENT_PORT");
const child = spawn("yarn", ["--cwd", "client", "vite", "--host", "127.0.0.1", "--port", port], {
cwd: root,
stdio: "inherit",
env: { ...process.env, VITE_API_URL: `http://127.0.0.1:${e2eEnv("E2E_API_PORT")}/api/v1` },
});
child.on("exit", (code) => process.exit(code ?? 1));

View File

@ -0,0 +1,19 @@
import { spawn } from "node:child_process";
import { assertE2EDatabase, e2eEnv, root } from "./env.mjs";
assertE2EDatabase();
const child = spawn("yarn", ["--cwd", "server", "dev"], {
cwd: root,
stdio: "inherit",
env: {
...process.env,
NODE_ENV: "e2e",
PORT: e2eEnv("E2E_API_PORT"),
MYSQL_DATABASE: e2eEnv("E2E_MYSQL_DATABASE"),
MYSQL_HOST: e2eEnv("E2E_MYSQL_HOST"),
MYSQL_PORT: e2eEnv("E2E_MYSQL_PORT"),
MYSQL_USER: e2eEnv("E2E_MYSQL_USER"),
MYSQL_USER_PASSWORD: e2eEnv("E2E_MYSQL_PASSWORD"),
},
});
child.on("exit", (code) => process.exit(code ?? 1));

View File

@ -16,13 +16,18 @@
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"docker:build": "docker compose --env-file stack.env build", "docker:build": "docker compose --env-file stack.env build",
"docker:ps": "docker compose ps", "docker:ps": "docker compose ps",
"docker:exec": "docker compose exec" "docker:exec": "docker compose exec",
"e2e:db:up": "node e2e/scripts/db.mjs up",
"e2e:db:down": "node e2e/scripts/db.mjs down",
"e2e": "node e2e/scripts/run-playwright.mjs",
"e2e:ui": "node e2e/scripts/run-playwright.mjs --ui"
}, },
"engines": { "engines": {
"node": ">=18.19.0", "node": ">=18.19.0",
"yarn": ">=1.22" "yarn": ">=1.22"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"concurrently": "4.1.0" "concurrently": "4.1.0"
}, },
"dependencies": { "dependencies": {

17
playwright.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";
const clientPort = process.env.E2E_CLIENT_PORT ?? "4173";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
fullyParallel: false,
retries: process.env.CI ? 2 : 0,
use: { baseURL: `http://127.0.0.1:${clientPort}`, storageState: "test-results/.auth.json", trace: "on-first-retry", screenshot: "only-on-failure", video: "off" },
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
globalSetup: "./e2e/fixtures/auth.ts",
webServer: [
{ command: "node e2e/scripts/start-server.mjs", url: `http://127.0.0.1:${process.env.E2E_API_PORT ?? "4101"}/api/v1/hello`, reuseExistingServer: false, timeout: 60_000 },
{ command: "node e2e/scripts/start-client.mjs", url: `http://127.0.0.1:${clientPort}`, reuseExistingServer: false, timeout: 60_000 },
],
});

View File

@ -0,0 +1,40 @@
const required = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`Missing required E2E environment variable: ${name}`);
return value;
};
const database = required("MYSQL_DATABASE");
if (!database.toLowerCase().includes("e2e")) {
throw new Error(`Refusing E2E startup for a non-E2E database: ${database}`);
}
module.exports = {
jwt: {
secret_key: required("E2E_JWT_SECRET"),
refresh_secret_key: required("E2E_JWT_REFRESH_SECRET"),
token_expiration: "1h",
refresh_token_expiration: "1h",
},
database: {
host: required("MYSQL_HOST"),
port: Number(required("MYSQL_PORT")),
dialect: "mysql",
database,
username: required("MYSQL_USER"),
password: required("MYSQL_USER_PASSWORD"),
},
server: { hostname: "127.0.0.1", port: Number(required("PORT")), cors_origin: "http://127.0.0.1" },
defaults: {
dealer_logos_upload_path: "/tmp/uecko-e2e/dealer-logos",
dealer_logo_placeholder: "/tmp/uecko-e2e/logo-placeholder.png",
support: { from: "e2e@example.test", subject: "E2E" },
},
admin: {
name: "E2E Admin", email: "e2e-admin@example.test", password: required("E2E_USER_PASSWORD"), language: "es",
},
sample_dealer: {
name: required("E2E_USER_NAME"), email: required("E2E_USER_EMAIL"), password: required("E2E_USER_PASSWORD"), language: "es",
},
nodemailer: { brevo: {} },
};

View File

@ -587,6 +587,13 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@playwright/test@^1.63.0":
version "1.63.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.63.0.tgz#808020c46e8b6b91f9d628cd0ebcbb32bdfd8b6c"
integrity sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==
dependencies:
playwright "1.63.0"
"@sideway/address@^4.1.5": "@sideway/address@^4.1.5":
version "4.1.5" version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
@ -2447,6 +2454,18 @@ pkg-dir@^4.2.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
playwright-core@1.63.0:
version "1.63.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.63.0.tgz#e57665bc32846c213ac39a1e4d5bc6228e76b376"
integrity sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==
playwright@1.63.0:
version "1.63.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.63.0.tgz#99b56f9f69b1b70c44f00bf84b2fe52348ae2511"
integrity sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==
dependencies:
playwright-core "1.63.0"
pretty-format@^29.0.0, pretty-format@^29.7.0: pretty-format@^29.0.0, pretty-format@^29.7.0:
version "29.7.0" version "29.7.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"