From 9d0fb2766ba914bd8378fbd3be6acfd8b180addd Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 22 May 2026 17:02:21 +0200 Subject: [PATCH] Add Playwright for end-to-end testing and update configuration - Added Playwright as a dependency for end-to-end testing, including necessary scripts in package.json. - Created a Playwright configuration file to define test settings and browser options. - Implemented a new GitHub Actions workflow for automated end-to-end testing on push and pull request events. - Updated .gitignore to exclude Playwright test results and reports. - Added initial end-to-end tests for mail functionalities, including composing, sending, and searching messages. --- .github/workflows/e2e.yml | 50 ++++++++++++++++++++++++++ .gitignore | 8 ++++- e2e/helpers/mail-app.ts | 56 +++++++++++++++++++++++++++++ e2e/mail-journeys.spec.ts | 74 +++++++++++++++++++++++++++++++++++++++ next.config.mjs | 11 +++++- package.json | 5 +++ playwright.config.ts | 32 +++++++++++++++++ pnpm-lock.yaml | 49 +++++++++++++++++++++++--- 8 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/helpers/mail-app.ts create mode 100644 e2e/mail-journeys.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a7649c7 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,50 @@ +name: E2E + +on: + push: + branches: [master, main] + pull_request: + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright e2e + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + env: + PLAYWRIGHT_BROWSERS_PATH: "0" + + - name: Run e2e tests + run: pnpm run e2e + env: + CI: true + PLAYWRIGHT_BROWSERS_PATH: "0" + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index f81b183..f3b8040 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,10 @@ __v0_jsx-dev-runtime.ts # Common ignores node_modules .next/ -.DS_Store \ No newline at end of file +.DS_Store + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ \ No newline at end of file diff --git a/e2e/helpers/mail-app.ts b/e2e/helpers/mail-app.ts new file mode 100644 index 0000000..3338fd0 --- /dev/null +++ b/e2e/helpers/mail-app.ts @@ -0,0 +1,56 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export async function gotoInbox(page: Page) { + await page.goto("/mail/inbox") + await expect(page.locator("[data-email-row-id]").first()).toBeVisible() +} + +export function composeWindow(page: Page): Locator { + return page.locator("[data-compose-window]").last() +} + +export async function openCompose(page: Page) { + const composeButton = page + .getByRole("button", { name: "Nouveau message" }) + .first() + await composeButton.click() + await expect(composeWindow(page)).toBeVisible() +} + +export async function fillCompose( + page: Page, + { + to, + subject, + body, + }: { + to: string + subject: string + body: string + } +) { + const compose = composeWindow(page) + const toField = compose.locator('input[type="text"]').first() + + await toField.fill(to) + await toField.press("Enter") + + await compose.getByPlaceholder("Objet").fill(subject) + await compose.locator(".ProseMirror").click() + await compose.locator(".ProseMirror").fill(body) +} + +export async function sendComposeNow(page: Page) { + const compose = composeWindow(page) + await compose.getByRole("button", { name: "Envoyer", exact: true }).click() + await page.getByRole("button", { name: "Envoyer maintenant" }).click() + await expect(page.getByText("Message envoyé")).toBeVisible() + await expect(compose).toHaveCount(0) +} + +export async function scheduleComposeInOneHour(page: Page) { + const compose = composeWindow(page) + await compose.locator("button.rounded-r-full").click() + await page.getByRole("menuitem", { name: "Envoyer dans une heure" }).click() + await expect(page.getByText(/Ce mail sera envoyé le/)).toBeVisible() +} diff --git a/e2e/mail-journeys.spec.ts b/e2e/mail-journeys.spec.ts new file mode 100644 index 0000000..8d1e8ab --- /dev/null +++ b/e2e/mail-journeys.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test" +import { + fillCompose, + gotoInbox, + openCompose, + scheduleComposeInOneHour, + sendComposeNow, +} from "./helpers/mail-app" + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.clear() + window.sessionStorage.clear() + }) +}) + +test.describe("mail journeys (mock data)", () => { + test("reads a message from inbox", async ({ page }) => { + await gotoInbox(page) + + await page.locator('[data-email-row-id="1"]').click() + + await expect(page).toHaveURL(/\/mail\/inbox\/message\/1/) + await expect(page.getByTitle("Sujet du message")).toBeVisible() + await expect(page.getByLabel("Répondre").first()).toBeVisible() + }) + + test("composes and sends a message", async ({ page }) => { + const subject = `E2E send ${Date.now()}` + + await gotoInbox(page) + await openCompose(page) + await fillCompose(page, { + to: "test@example.com", + subject, + body: "Playwright send journey", + }) + await sendComposeNow(page) + }) + + test("schedules a message for later", async ({ page }) => { + const subject = `E2E schedule ${Date.now()}` + + await gotoInbox(page) + await openCompose(page) + await fillCompose(page, { + to: "scheduled@example.com", + subject, + body: "Playwright schedule journey", + }) + await scheduleComposeInOneHour(page) + + await expect(page.getByText(/Ce mail sera envoyé le/)).toBeVisible() + await page.waitForFunction( + (expectedSubject) => { + const raw = localStorage.getItem("ultimail-scheduled-state") + return raw?.includes(expectedSubject) ?? false + }, + subject, + { timeout: 10_000 } + ) + }) + + test("searches messages", async ({ page }) => { + await gotoInbox(page) + + const search = page.getByPlaceholder("Rechercher dans les messages") + await search.fill("Uber") + await search.press("Enter") + + await expect(page).toHaveURL(/\/mail\/search/) + await expect(page.locator("[data-email-row-id]").first()).toContainText("Uber") + }) +}) diff --git a/next.config.mjs b/next.config.mjs index c71ec5f..b39db28 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,9 +2,18 @@ * Copyright (c) 2026 Eliott Guillaumin * All rights reserved. */ +import path from "node:path" +import { fileURLToPath } from "node:url" + /** @type {import('next').NextConfig} */ +const projectRoot = path.dirname(fileURLToPath(import.meta.url)) + const nextConfig = { - output: 'standalone', + output: "standalone", + outputFileTracingRoot: projectRoot, + turbopack: { + root: projectRoot, + }, allowedDevOrigins: ['192.168.0.20', '127.0.0.1', 'localhost', '100.120.4.66'], typescript: { ignoreBuildErrors: true, diff --git a/package.json b/package.json index 02dae7e..19b322c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:headed": "playwright test --headed", + "e2e:server": "pnpm build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/static && node .next/standalone/server.js", "expose": "source .env && cloudflared tunnel --loglevel error run --token $CLOUDFLARE_TUNNEL_TOKEN", "brand:raster": "node scripts/rasterize-ultimail-brand.mjs", "brand:vectorize": "node scripts/vectorize-ultimail-brand.mjs", @@ -86,6 +90,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^22", "@types/react": "^19", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d7f1a5f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test" + +const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 3099) +const baseURL = `http://127.0.0.1:${PORT}` + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + timeout: 60_000, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: `HOSTNAME=127.0.0.1 PORT=${PORT} pnpm run e2e:server`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 300_000, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b3df55..4dec39c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: version: 3.23.2 '@vercel/analytics': specifier: 1.6.1 - version: 1.6.1(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 1.6.1(next@16.2.6(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) autoprefixer: specifier: ^10.4.20 version: 10.4.24(postcss@8.5.6) @@ -184,7 +184,7 @@ importers: version: 0.564.0(react@19.2.4) next: specifier: 16.2.6 - version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -222,6 +222,9 @@ importers: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.60.0 '@tailwindcss/postcss': specifier: ^4.2.0 version: 4.2.0 @@ -522,6 +525,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1655,6 +1663,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fuse.js@7.3.0: resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==} engines: {node: '>=10'} @@ -1816,6 +1829,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -2289,6 +2312,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -3292,9 +3319,9 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@vercel/analytics@1.6.1(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@1.6.1(next@16.2.6(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 aria-hidden@1.2.6: @@ -3432,6 +3459,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fuse.js@7.3.0: {} get-nonce@1.0.1: {} @@ -3521,7 +3551,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -3540,6 +3570,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -3553,6 +3584,14 @@ snapshots: picocolors@1.1.1: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-value-parser@4.2.0: {} postcss@8.4.31: