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
|
/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
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: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
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"
|
"@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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user