Compare commits

...

2 Commits

22 changed files with 508 additions and 266 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
.env
.env.e2e
test-results/
playwright-report/
.passport.js
.DS_Store
@ -28,4 +31,3 @@ server/uploads/dealer-logos/
# misc
.DS_Store
*.pem

1
dist/client/assets/index-CBtV2-Es.css vendored Normal file

File diff suppressed because one or more lines are too long

254
dist/client/assets/index-DP3f1470.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,8 +8,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Presupuestador Uecko</title>
<script type="module" crossorigin src="/assets/index-B09LVASt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-lj6iaWPa.css">
<script type="module" crossorigin src="/assets/index-D_mpiX-a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBtV2-Es.css">
</head>
<body>

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,16 +16,21 @@
"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": {
"typescript": "^5.2.2"
}
}
}

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"
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"