diff --git a/.env.test b/.env.test index 64bd1114..aed1f85b 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,26 @@ -# define your env variables for the test env here -KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' +# .env.test — committed defaults for the `test` environment (E2E + PHPUnit). +# Real environment variables and .env.test.local override anything here. +# DO NOT put real secrets in this file; these values are throwaway CI defaults. + +APP_ENV=test +APP_DEBUG=1 + +# Fixed so CSRF tokens are stable within a run. Test-only value. +APP_SECRET=282065e0f71bf96c0dbcf0496c1f5d3e + +# Throwaway libsodium key for the encrypted_string Doctrine type. Test-only. +# Regenerate with: php -r 'echo base64_encode(sodium_crypto_secretbox_keygen());' +APP_ENCRYPTION_KEY=emL6R8zs0pSXCVV2HvVvbFubriWJIla7kgnqSGqCJ8E= + +# No real mail is sent, and async work is collected but not processed so the +# suite never needs a running worker. +MAILER_DSN=null://null +MESSENGER_TRANSPORT_DSN=in-memory:// + +# Single source of truth for the seeded test user. The seed command, the +# login template prefill, and the Playwright specs all read these. +APP_DEV_USER_EMAIL=e2e@plmail.test +APP_DEV_USER_PASSWORD=e2e-password-change-me + +# Local default DB. CI overrides DATABASE_URL to point at its Postgres service. +DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=18&charset=utf8" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..1289f543 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,100 @@ +name: E2E + +on: + push: + pull_request: + +jobs: + playwright: + name: Playwright (login + account) + runs-on: ubuntu-latest + + env: + APP_ENV: test + APP_DEBUG: "1" + DATABASE_URL: "postgresql://app:app@127.0.0.1:5432/app?serverVersion=18&charset=utf8" + MESSENGER_TRANSPORT_DSN: "in-memory://" + MAILER_DSN: "null://null" + APP_DEV_USER_EMAIL: "e2e@plmail.test" + APP_DEV_USER_PASSWORD: "e2e-password-change-me" + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: ctype, iconv, pcntl, pdo_pgsql, pgsql, intl, sodium, mbstring, xml, curl, zip + coverage: none + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ hashFiles('**/composer.lock') }} + restore-keys: composer- + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Install Symfony CLI + run: | + curl -1sLf 'https://get.symfony.com/cli/setup.deb.sh' | sudo -E bash + sudo apt-get install -y symfony-cli + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Node dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build frontend assets + run: | + php bin/console importmap:install + php bin/console tailwind:build + + - name: Prepare database + run: | + php bin/console doctrine:database:create --if-not-exists --no-interaction + php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration + + - name: Seed E2E user + run: php bin/console app:e2e:seed-user + + - name: Run Playwright tests + run: npx playwright test + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9b174809 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "plmail-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plmail-e2e", + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^22.10.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..342e6195 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "plmail-e2e", + "private": true, + "description": "End-to-end tests for plMail (Playwright). Separate from the app's AssetMapper/importmap frontend.", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^22.10.0", + "typescript": "^5.6.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..62578c5b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * plMail end-to-end configuration. + * + * The app is booted via the Symfony local server in the `test` environment. + * A dedicated `setup` project logs in through the real UI once and saves the + * authenticated storage state, which the main `chromium` project reuses so + * every non-auth spec starts already signed in. + */ + +const BASE_URL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:8000"; +const STORAGE_STATE = "playwright/.auth/user.json"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [ + { + name: "setup", + testMatch: /.*\.setup\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: STORAGE_STATE, + }, + dependencies: ["setup"], + }, + ], + + webServer: { + command: "symfony serve --port=8000 --no-tls --allow-http", + url: `${BASE_URL}/login`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + APP_ENV: "test", + APP_DEBUG: "1", + }, + }, +}); diff --git a/src/Command/Test/SeedTestUserCommand.php b/src/Command/Test/SeedTestUserCommand.php new file mode 100644 index 00000000..9c06e3ce --- /dev/null +++ b/src/Command/Test/SeedTestUserCommand.php @@ -0,0 +1,101 @@ +addOption('email', null, InputOption::VALUE_REQUIRED, 'Override the seeded email') + ->addOption('password', null, InputOption::VALUE_REQUIRED, 'Override the seeded password'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ('prod' === $this->environment) { + $io->error('app:e2e:seed-user must not run in the prod environment.'); + + return Command::FAILURE; + } + + $email = $input->getOption('email') + ?? $_SERVER['APP_DEV_USER_EMAIL'] + ?? 'e2e@plmail.test'; + + $plainPassword = $input->getOption('password') + ?? $_SERVER['APP_DEV_USER_PASSWORD'] + ?? 'e2e-password-change-me'; + + $user = $this->userRepository->findOneBy(['email' => $email]); + $created = false; + + if (null === $user) { + $user = new User(); + $user + ->setEmail($email) + ->setNameFirst('E2E') + ->setNameLast('Tester') + ->setRoles([User::ROLE_USER]); + + $created = true; + } + + $user->setPassword( + $this->passwordHasher->hashPassword($user, $plainPassword) + ); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + if (true === $created) { + $io->success(sprintf('Created E2E user "%s".', $email)); + } else { + $io->success(sprintf('Updated E2E user "%s".', $email)); + } + + return Command::SUCCESS; + } +} diff --git a/tests/Controller/MailControllerTest.php b/tests/Controller/MailControllerTest.php deleted file mode 100644 index cbb0e965..00000000 --- a/tests/Controller/MailControllerTest.php +++ /dev/null @@ -1,16 +0,0 @@ -request('GET', '/mail'); - - self::assertResponseIsSuccessful(); - } -} diff --git a/tests/account.spec.ts b/tests/account.spec.ts new file mode 100644 index 00000000..d21328f0 --- /dev/null +++ b/tests/account.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from "@playwright/test"; + +/** + * Runs authenticated via the shared storage state from auth.setup.ts. + * + * Adding a password (IMAP) account only persists the row — no IMAP + * connection is attempted at creation time — so a fake host is fine and the + * test needs no mail server. + */ +test.describe("mail account creation", () => { + test("adds an IMAP account from the settings modal", async ({ page }) => { + // Unique per run so re-runs against a non-fresh DB stay unambiguous. + const stamp = Date.now(); + const label = `E2E IMAP ${stamp}`; + const username = `imap-${stamp}@example.test`; + const host = `imap-${stamp}.example.test`; + + await page.goto("/settings?section=accounts"); + + const accountsSection = page + .locator("section") + .filter({ has: page.locator("#settings-account-list") }); + + await accountsSection + .getByRole("button", { name: "Add account" }) + .click(); + + // Wait for the Turbo-Frame modal form to load. + const imapHost = page.locator('input[name="account[imapHost]"]'); + await expect(imapHost).toBeVisible(); + + // The IMAP/SMTP tab is active by default, so these fields are visible. + await page.locator('input[name="account[email]"]').fill(label); + await page.locator('input[name="account[username]"]').fill(username); + await page.locator('input[name="account[password]"]').fill("hunter2"); + await imapHost.fill(host); + await page.locator('input[name="account[imapPort]"]').fill("993"); + await page + .locator('select[name="account[imapEncryption]"]') + .selectOption("ssl"); + + await page.locator('#modal button[type="submit"]').click(); + + // Turbo Stream: success toast + refreshed settings list. + await expect(page.getByText("Account added successfully")).toBeVisible(); + + const list = page.locator("#settings-account-list"); + await expect(list).toContainText(label); + await expect(list).toContainText(host); + }); + + test("keeps the modal open and reports the error when required fields are missing", async ({ + page, + }) => { + await page.goto("/settings?section=accounts"); + + const accountsSection = page + .locator("section") + .filter({ has: page.locator("#settings-account-list") }); + + await accountsSection + .getByRole("button", { name: "Add account" }) + .click(); + + const imapHost = page.locator('input[name="account[imapHost]"]'); + await expect(imapHost).toBeVisible(); + + // Submit with the required fields blank. + await page.locator('#modal button[type="submit"]').click(); + + // Required fields (native HTML5 + server-side NotBlank) keep us on the + // form inside the modal rather than emitting the success toast. + await expect(page.locator('input[name="account[imapHost]"]')).toBeVisible(); + await expect(page.getByText("Account added successfully")).toHaveCount(0); + }); +}); diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts new file mode 100644 index 00000000..715cf8a1 --- /dev/null +++ b/tests/auth.setup.ts @@ -0,0 +1,13 @@ +import { test as setup } from "@playwright/test"; +import { login } from "./support/config"; + +const STORAGE_STATE = "playwright/.auth/user.json"; + +/** + * Signs in through the real UI and saves the authenticated cookies so the + * main project can reuse the session without re-logging in per test. + */ +setup("authenticate", async ({ page }) => { + await login(page); + await page.context().storageState({ path: STORAGE_STATE }); +}); diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 00000000..8e557192 --- /dev/null +++ b/tests/auth.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; +import { TEST_USER, login } from "./support/config"; + +/** + * Login is exercised in a fresh, unauthenticated context — separate from the + * shared storage state produced by auth.setup.ts. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("login", () => { + test("signs in with valid credentials and lands on the inbox", async ({ + page, + }) => { + await login(page); + + // The login form is gone once we're in the authenticated shell. + await expect(page.locator("#inputEmail")).toHaveCount(0); + }); + + test("rejects invalid credentials and stays on the login page", async ({ + page, + }) => { + await page.goto("/login"); + + await page.locator("#inputEmail").fill(TEST_USER.email); + await page.locator("#password").fill("definitely-the-wrong-password"); + await page.getByRole("button", { name: "Sign in" }).click(); + + // The authenticator redirects back to /login on failure. + await expect(page).toHaveURL(/\/login/); + await expect(page.locator("#password")).toBeVisible(); + }); + + test("redirects anonymous users away from a protected page", async ({ + page, + }) => { + await page.goto("/settings?section=accounts"); + + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 47a58557..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,13 +0,0 @@ -bootEnv(dirname(__DIR__).'/.env'); -} - -if ($_SERVER['APP_DEBUG']) { - umask(0000); -} diff --git a/tests/config.ts b/tests/config.ts new file mode 100644 index 00000000..7c701830 --- /dev/null +++ b/tests/config.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Single source of truth for the seeded test user. Mirrors the + * APP_DEV_USER_EMAIL / APP_DEV_USER_PASSWORD env vars consumed by the + * `app:e2e:seed-user` console command. + */ +export const TEST_USER = { + email: process.env.APP_DEV_USER_EMAIL ?? "e2e@plmail.test", + password: process.env.APP_DEV_USER_PASSWORD ?? "e2e-password-change-me", +}; + +/** + * Drives the real login form at /login and waits for the authenticated + * shell to land on the inbox. + */ +export async function login( + page: Page, + email: string = TEST_USER.email, + password: string = TEST_USER.password, +): Promise { + await page.goto("/login"); + + // The template may prefill from APP_DEV_USER_*; fill explicitly so the + // test exercises real input regardless. + await page.locator("#inputEmail").fill(email); + await page.locator("#password").fill(password); + + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page).toHaveURL(/\/mail\/inbox/); + await expect( + page.getByRole("button", { name: `User menu for ${email}` }), + ).toBeVisible(); +} diff --git a/tsconfig.lock b/tsconfig.lock new file mode 100644 index 00000000..fb4a6586 --- /dev/null +++ b/tsconfig.lock @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["tests/e2e/**/*.ts", "playwright.config.ts"] +}