Add Playwright for end-to-end testing and update configuration
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-05-22 17:02:21 +02:00
parent bd50a4a54c
commit 9d0fb2766b
8 changed files with 278 additions and 7 deletions

50
.github/workflows/e2e.yml vendored Normal file
View File

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

8
.gitignore vendored
View File

@ -13,4 +13,10 @@ __v0_jsx-dev-runtime.ts
# Common ignores
node_modules
.next/
.DS_Store
.DS_Store
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

56
e2e/helpers/mail-app.ts Normal file
View File

@ -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()
}

74
e2e/mail-journeys.spec.ts Normal file
View File

@ -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")
})
})

View File

@ -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,

View File

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

32
playwright.config.ts Normal file
View File

@ -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,
},
})

View File

@ -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: