This commit is contained in:
Paul Lützner committed 2026-07-21 16:18:46 +02:00
1 parent cd3c78c205
commit 517bd5d345
13 files changed
+588 -32

No files matched your search

+26 -3
View File
@@ -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"
+100
View File
@@ -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
+109
View File
@@ -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"
}
}
}
+17
View File
@@ -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"
}
}
+57
View File
@@ -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",
},
},
});
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Command\Test;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Seeds a single, known user for end-to-end tests.
*
* Idempotent: find-or-create by email, and the password is (re)hashed on
* every run so the credentials always match what the E2E suite expects.
*
* Credentials default to the APP_DEV_USER_EMAIL / APP_DEV_USER_PASSWORD
* environment variables so the console command, the login template prefill,
* and the Playwright specs all read from a single source of truth.
*
* Refuses to run in prod — this is a test-fixture tool.
*/
#[AsCommand(
name: 'app:test:seed-user',
description: 'Create or update the known user used by the end-to-end test suite',
)]
final class SeedTestUserCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class MailControllerTest extends WebTestCase
{
public function testIndex(): void
{
$client = static::createClient();
$client->request('GET', '/mail');
self::assertResponseIsSuccessful();
}
}
+76
View File
@@ -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);
});
});
+13
View File
@@ -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 });
});
+41
View File
@@ -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/);
});
});
-13
View File
@@ -1,13 +0,0 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}
+35
View File
@@ -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<void> {
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();
}
+13
View File
@@ -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"]
}