Añadir configuración y scripts para pruebas E2E, incluyendo base de datos y autenticación
This commit is contained in:
parent
31d8e8ffd6
commit
6098ef840a
13
.env.e2e.example
Normal file
13
.env.e2e.example
Normal 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
4
.gitignore
vendored
@ -6,6 +6,9 @@ npm-debug.log
|
||||
/public
|
||||
|
||||
.env
|
||||
.env.e2e
|
||||
test-results/
|
||||
playwright-report/
|
||||
.passport.js
|
||||
.DS_Store
|
||||
|
||||
@ -28,4 +31,3 @@ server/uploads/dealer-logos/
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
|
||||
19
docker-compose.e2e.yml
Normal file
19
docker-compose.e2e.yml
Normal 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
7
e2e/README.md
Normal 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
18
e2e/fixtures/auth.ts
Normal 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
1
e2e/fixtures/quotes.ts
Normal file
@ -0,0 +1 @@
|
||||
export const uniqueCustomerReference = () => `E2E-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
21
e2e/quotes/create-and-edit.spec.ts
Normal file
21
e2e/quotes/create-and-edit.spec.ts
Normal 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
16
e2e/scripts/db.mjs
Normal 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
28
e2e/scripts/env.mjs
Normal 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 };
|
||||
7
e2e/scripts/run-playwright.mjs
Normal file
7
e2e/scripts/run-playwright.mjs
Normal 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));
|
||||
10
e2e/scripts/start-client.mjs
Normal file
10
e2e/scripts/start-client.mjs
Normal 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));
|
||||
19
e2e/scripts/start-server.mjs
Normal file
19
e2e/scripts/start-server.mjs
Normal 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));
|
||||
@ -16,13 +16,18 @@
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"docker:build": "docker compose --env-file stack.env build",
|
||||
"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": {
|
||||
"node": ">=18.19.0",
|
||||
"yarn": ">=1.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"concurrently": "4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
17
playwright.config.ts
Normal file
17
playwright.config.ts
Normal 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 },
|
||||
],
|
||||
});
|
||||
40
server/src/config/environments/e2e.ts
Normal file
40
server/src/config/environments/e2e.ts
Normal 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: {} },
|
||||
};
|
||||
19
yarn.lock
19
yarn.lock
@ -587,6 +587,13 @@
|
||||
"@nodelib/fs.scandir" "2.1.5"
|
||||
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":
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
|
||||
@ -2447,6 +2454,18 @@ pkg-dir@^4.2.0:
|
||||
dependencies:
|
||||
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:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user