diff --git a/.env.e2e.example b/.env.e2e.example new file mode 100644 index 0000000..139db7a --- /dev/null +++ b/.env.e2e.example @@ -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 diff --git a/.gitignore b/.gitignore index ab2eac8..93da082 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..13ce236 --- /dev/null +++ b/docker-compose.e2e.yml @@ -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 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..2439d08 --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..eba300e --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -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(); +} diff --git a/e2e/fixtures/quotes.ts b/e2e/fixtures/quotes.ts new file mode 100644 index 0000000..8bce599 --- /dev/null +++ b/e2e/fixtures/quotes.ts @@ -0,0 +1 @@ +export const uniqueCustomerReference = () => `E2E-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; diff --git a/e2e/quotes/create-and-edit.spec.ts b/e2e/quotes/create-and-edit.spec.ts new file mode 100644 index 0000000..5b8787f --- /dev/null +++ b/e2e/quotes/create-and-edit.spec.ts @@ -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/); +}); diff --git a/e2e/scripts/db.mjs b/e2e/scripts/db.mjs new file mode 100644 index 0000000..cd1cc3a --- /dev/null +++ b/e2e/scripts/db.mjs @@ -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); diff --git a/e2e/scripts/env.mjs b/e2e/scripts/env.mjs new file mode 100644 index 0000000..eafaf62 --- /dev/null +++ b/e2e/scripts/env.mjs @@ -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 }; diff --git a/e2e/scripts/run-playwright.mjs b/e2e/scripts/run-playwright.mjs new file mode 100644 index 0000000..b038195 --- /dev/null +++ b/e2e/scripts/run-playwright.mjs @@ -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)); diff --git a/e2e/scripts/start-client.mjs b/e2e/scripts/start-client.mjs new file mode 100644 index 0000000..dd75829 --- /dev/null +++ b/e2e/scripts/start-client.mjs @@ -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)); diff --git a/e2e/scripts/start-server.mjs b/e2e/scripts/start-server.mjs new file mode 100644 index 0000000..364913f --- /dev/null +++ b/e2e/scripts/start-server.mjs @@ -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)); diff --git a/package.json b/package.json index c9858c0..0854228 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..04d1ef4 --- /dev/null +++ b/playwright.config.ts @@ -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 }, + ], +}); diff --git a/server/src/config/environments/e2e.ts b/server/src/config/environments/e2e.ts new file mode 100644 index 0000000..5ce3790 --- /dev/null +++ b/server/src/config/environments/e2e.ts @@ -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: {} }, +}; diff --git a/yarn.lock b/yarn.lock index 8640972..27dc874 100644 --- a/yarn.lock +++ b/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"