From 565fcf617fb42ae295cc125e6161fe3ade51bd36 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 2 Oct 2026 10:05:07 +0200 Subject: [PATCH 1/2] Allow all types of applications to be tested --- README.md | 2 +- e2e-testing/README.md | 72 +++++++++- .../AbstractApplicationTestCase.php | 20 +++ e2e-testing/src/Application/Application.php | 56 ++++++++ .../src/Application/ApplicationConfig.php | 55 ++++++++ .../ApplicationConfigInterface.php | 18 +++ .../src/Application/ApplicationInterface.php | 32 +++++ .../src/Application/ApplicationTestTrait.php | 130 ++++++++++++++++++ e2e-testing/src/Browser/BrowserRuntime.php | 90 ++++++++++++ .../BrowserSessionFactoryInterface.php | 20 +++ e2e-testing/src/Browser/PlaywrightManager.php | 2 +- .../AbstractManagedEditionTestCase.php | 2 +- .../src/ManagedEdition/ManagedEdition.php | 87 ++++-------- .../ManagedEdition/ManagedEditionConfig.php | 8 +- .../ManagedEditionTestTrait.php | 103 ++------------ e2e-testing/tests/ApplicationConfigTest.php | 54 ++++++++ e2e-testing/tests/ApplicationTest.php | 64 +++++++++ .../tests/ApplicationTestTraitTest.php | 73 ++++++++++ e2e-testing/tests/BrowserRuntimeTest.php | 130 ++++++++++++++++++ .../AbstractManagedEditionTestCase.php | 2 +- 20 files changed, 850 insertions(+), 170 deletions(-) create mode 100644 e2e-testing/src/Application/AbstractApplicationTestCase.php create mode 100644 e2e-testing/src/Application/Application.php create mode 100644 e2e-testing/src/Application/ApplicationConfig.php create mode 100644 e2e-testing/src/Application/ApplicationConfigInterface.php create mode 100644 e2e-testing/src/Application/ApplicationInterface.php create mode 100644 e2e-testing/src/Application/ApplicationTestTrait.php create mode 100644 e2e-testing/src/Browser/BrowserRuntime.php create mode 100644 e2e-testing/src/Browser/BrowserSessionFactoryInterface.php create mode 100644 e2e-testing/tests/ApplicationConfigTest.php create mode 100644 e2e-testing/tests/ApplicationTest.php create mode 100644 e2e-testing/tests/ApplicationTestTraitTest.php create mode 100644 e2e-testing/tests/BrowserRuntimeTest.php diff --git a/README.md b/README.md index fecd853..a19321d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This monorepo develops two packages on a shared version line that is independent | Package | Responsibility | Use it for | | --- | --- | --- | | [`contao/installation-recipe`](installation-recipe/) | Defines and applies portable recipes containing Composer requirements, configuration, database fixtures, and project files. The host application supplies operations such as dependency installation and migration. | Building an installer or importer that consumes recipes. | -| [`contao/e2e-testing`](e2e-testing/) | Consumes those recipes to provision isolated Contao Managed Editions for tests, with a database, installation cache, web server, and HTTP and browser clients. | Testing a Contao project through HTTP requests or a real browser from PHPUnit. | +| [`contao/e2e-testing`](e2e-testing/) | Provides browser testing for existing web applications and consumes recipes to provision isolated Contao Managed Editions with a database, installation cache, web server, and HTTP clients. | Testing Contao extensions, complete Contao projects, or other web applications from PHPUnit. | The dependency goes from `contao/e2e-testing` to `contao/installation-recipe`. They live together so changes to the recipe model and its test consumer can be tested atomically. Both packages are released independently of `contao/contao`, and the consuming project selects the Contao version to test. diff --git a/e2e-testing/README.md b/e2e-testing/README.md index 686bdab..ceb29d9 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -1,6 +1,6 @@ # Contao E2E testing -`contao/e2e-testing` owns the test runtime. It consumes recipes from `contao/installation-recipe` to prepare a real Contao Managed Edition and migrate an isolated MySQL/MariaDB database. Tests can make direct HTTP requests, use Symfony BrowserKit for HTTP tests without JavaScript, or drive a real browser with Playwright. The test suite selects the Contao version in its recipe because this library does not require a Contao bundle. +`contao/e2e-testing` owns the test runtime for Contao and other web applications. It can test an already running application through its base URL, or consume recipes from `contao/installation-recipe` to prepare a real Contao Managed Edition and migrate an isolated MySQL/MariaDB database. Tests can make direct HTTP requests, use Symfony BrowserKit for HTTP tests without JavaScript, or drive a real browser with Playwright. Managed Edition tests select the Contao version in their recipe because this library does not require a Contao bundle. Install it as a development dependency in the project under test. Composer also installs `contao/installation-recipe`, which provides the recipe model used below: @@ -8,9 +8,56 @@ Install it as a development dependency in the project under test. Composer also composer require --dev contao/e2e-testing ``` +## Test an existing application + +Use `AbstractApplicationTestCase` to test any running web application. The application can be a complete Contao project or use another framework or language. Start its server before PHPUnit, then supply its base URL: + +```php +use Contao\E2eTesting\Application\AbstractApplicationTestCase; +use Contao\E2eTesting\Application\ApplicationConfig; + +final class HomepageTest extends AbstractApplicationTestCase +{ + protected static function createApplicationConfig(): ApplicationConfig + { + return ApplicationConfig::create( + getenv('E2E_BASE_URL') ?: 'http://localhost:8080', + )->withTraceDirectory(dirname(__DIR__, 2).'/.contao-e2e/traces'); + } + + public function testHomepage(): void + { + $browser = self::application()->createBrowser(); + $browser->visit('/'); + + $this->assertSelectorTextContains('h1', 'Welcome'); + } +} +``` + +Install the browser binaries as described in [Browser tests](#browser-tests), add the tests to your PHPUnit configuration, and run them with the application's URL: + +```shell +E2E_BASE_URL=http://localhost:8080 vendor/bin/phpunit --testsuite=e2e +``` + +`ApplicationTestTrait` supplies the same integration when your tests already extend another PHPUnit base class. Each test starts with fresh browser contexts and independent cookies and storage. The browser engine is reused across the class and closed at the end. Your project controls server startup, application configuration, database fixtures and any application state reset between tests. + +`ApplicationConfig::create()` accepts an absolute HTTP or HTTPS URL, including a base path such as `https://example.test/app`. `withTraceDirectory()` returns a cloned configuration. The default trace directory is `.contao-e2e/traces` relative to the working directory. Browser engine selection, `BrowserOptions`, Playwright environment variables, and `CONTAO_E2E_TRACE` work as described below for both application modes. + +For an existing Contao project, frontend tests use `createBrowser()` and backend tests can use the usual helpers: + +```php +$backend = self::application()->createBackendBrowser(); +$backend->visit('/contao/login'); +$backend->submitLogin('admin', 'password'); +``` + +For use outside the PHPUnit traits, create `Application` with the configuration, call `createBrowser()` or `createBackendBrowser()`, and call `release()` in a `finally` block. `BrowserRuntime` owns session tracking, current-page access, trace output and cleanup. `ApplicationTestTrait` supplies the shared PHPUnit lifecycle, assertions and tracing for all application tests. `ManagedEditionTestTrait` supplies the Managed Edition configuration type and access to Contao-specific operations. Provisioning and database resets belong to the configuration and application implementations. `ApplicationInterface::resetState()` defines the reset between tests. URL-based applications close their browser contexts, while Managed Editions also restore their database fixtures, including when used directly with `ApplicationTestTrait`. Both configurations implement `ApplicationConfigInterface`, which creates an `ApplicationInterface` for the shared lifecycle. Custom configurations can implement the same contract without changing the trait. + ## Database setup -If Docker is available, no database setup is needed. The first test starts a reusable `mariadb:11.4` container on a random loopback port. The last E2E process stops it, and subsequent runs restart the same container. Its `/var/lib/mysql` directory is bind-mounted to `.contao-e2e/database/data`, so all generated database files remain inside the project-local E2E workspace. Parallel test workers keep shared leases and only the final worker stops the database. If a process is killed before PHP can run its shutdown handlers, `database:stop` cleans up any remaining containers. +For Managed Edition tests, no database setup is needed when Docker is available. The first test starts a reusable `mariadb:11.4` container on a random loopback port. The last E2E process stops it, and subsequent runs restart the same container. Its `/var/lib/mysql` directory is bind-mounted to `.contao-e2e/database/data`, so all generated database files remain inside the project-local E2E workspace. Parallel test workers keep shared leases and only the final worker stops the database. If a process is killed before PHP can run its shutdown handlers, `database:stop` cleans up any remaining containers. Select a database explicitly in the PHPUnit configuration when an extension supports a particular database range: @@ -49,7 +96,7 @@ final class ManagedEditionSmokeTest extends TestCase implements DockerServicePro { use ManagedEditionTestTrait; - protected static function createManagedEditionConfig(): ManagedEditionConfig + protected static function createApplicationConfig(): ManagedEditionConfig { $bundleRoot = dirname(__DIR__, 2); $composer = ComposerConfig::managedEdition('^5.7') @@ -60,7 +107,7 @@ final class ManagedEditionSmokeTest extends TestCase implements DockerServicePro public static function dockerServices(): iterable { - return static::createManagedEditionConfig()->dockerServices(); + return static::createApplicationConfig()->dockerServices(); } } ``` @@ -95,7 +142,7 @@ $devConfig = $config->withAppEnvironment('dev'); $prodConfig = $config->withAppEnvironment('prod'); ``` -Changing the environment refreshes the cached application setup. The selected environment applies to Contao setup commands, database migration, and HTTP requests. With `ManagedEditionTestTrait`, return the desired configuration from `createManagedEditionConfig()` for each test class. +Changing the environment refreshes the cached application setup. The selected environment applies to Contao setup commands, database migration, and HTTP requests. With `ManagedEditionTestTrait`, return the desired configuration from `createApplicationConfig()` for each test class. ## Browser tests @@ -193,7 +240,7 @@ use Contao\InstallationRecipe\Recipe\InstallationRecipe; final class ManagedEditionSmokeTest extends AbstractManagedEditionTestCase { - protected static function createManagedEditionConfig(): ManagedEditionConfig + protected static function createApplicationConfig(): ManagedEditionConfig { $bundleRoot = dirname(__DIR__, 2); $composer = ComposerConfig::managedEdition('^5.7') @@ -240,6 +287,19 @@ vendor/bin/phpunit --configuration=phpunit.xml.dist tests/E2e/ManagedEditionSmok The trait works with PHPUnit 10 through 13 and does not impose a test base class. Once the smoke test runs, replace its login-page assertion with checks for your bundle's behavior. Add database fixtures with `InstallationRecipe::withFixtureFile()` when the test needs existing pages or backend users. +### Frontend tests in a Managed Edition + +The same isolated Contao installation can serve frontend and backend tests. Prepare the page structure and content through recipe fixtures, and add project files such as templates and assets through recipe file mappings. Then visit a frontend URL with the generic browser: + +```php +$browser = self::managedEdition()->createBrowser(); +$browser->visit('/'); + +$this->assertSelectorTextContains('h1', 'Welcome'); +``` + +Use `Origin::http('example.test')` or `Origin::https('example.test')` when a frontend fixture needs a specific page domain. Managed Edition mode builds a recipe-based test installation. URL-based application tests exercise the project served at the supplied URL. + ### Test-specific DCA Use `withDcaFile()` to add a PHP DCA file from the test suite to the Managed Edition. The file is copied to the project's `contao/dca/` directory before Contao setup and database migration. Its basename determines the DCA file name, so a source named `tl_content.php` configures `tl_content`: diff --git a/e2e-testing/src/Application/AbstractApplicationTestCase.php b/e2e-testing/src/Application/AbstractApplicationTestCase.php new file mode 100644 index 0000000..19a8277 --- /dev/null +++ b/e2e-testing/src/Application/AbstractApplicationTestCase.php @@ -0,0 +1,20 @@ +browserRuntime = $browserRuntime ?? new BrowserRuntime($config->traceDirectory()); + } + + public function createBrowser(BrowserType $type = BrowserType::Firefox, BrowserOptions|null $options = null): BrowserSession + { + return $this->browserRuntime->createBrowser($this->config->baseUri, $type, $options); + } + + public function createBackendBrowser(BrowserType $type = BrowserType::Firefox, BrowserOptions|null $options = null): BackendBrowser + { + return new BackendBrowser($this->createBrowser($type, $options)); + } + + public function browserRuntime(): BrowserRuntime + { + return $this->browserRuntime; + } + + public function resetState(): void + { + $this->browserRuntime->reset(); + } + + public function release(): void + { + $this->browserRuntime->close(); + } +} diff --git a/e2e-testing/src/Application/ApplicationConfig.php b/e2e-testing/src/Application/ApplicationConfig.php new file mode 100644 index 0000000..3fc5a4c --- /dev/null +++ b/e2e-testing/src/Application/ApplicationConfig.php @@ -0,0 +1,55 @@ +traceDirectory; + } + + public function withTraceDirectory(string $traceDirectory): self + { + if ('' === trim($traceDirectory)) { + throw new \InvalidArgumentException('The trace directory must not be empty.'); + } + + $clone = clone $this; + $clone->traceDirectory = $traceDirectory; + + return $clone; + } +} diff --git a/e2e-testing/src/Application/ApplicationConfigInterface.php b/e2e-testing/src/Application/ApplicationConfigInterface.php new file mode 100644 index 0000000..94c36c2 --- /dev/null +++ b/e2e-testing/src/Application/ApplicationConfigInterface.php @@ -0,0 +1,18 @@ +createApplication(); + self::$applicationFresh = true; + } + + #[AfterClass] + public static function releaseApplication(): void + { + self::$application?->release(); + self::$application = null; + self::$applicationFresh = false; + } + + public function assertSelectorExists(string $selector, string $message = ''): void + { + $locator = self::currentBrowserPage()->locator($selector); + Expect::locator($locator->first())->toBeAttached(new AssertionOptions(message: $message ?: null)); + Assert::assertGreaterThan(0, $locator->count(), $message); + } + + public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void + { + $locator = self::currentBrowserPage()->locator($selector); + Expect::locator($locator)->toContainText($text, new AssertionOptions(message: $message ?: null)); + Assert::assertGreaterThan(0, $locator->count(), $message); + Assert::assertStringContainsString($text, $locator->first()->innerText(), $message); + } + + abstract protected static function createApplicationConfig(): ApplicationConfigInterface; + + #[Before] + protected function resetApplication(): void + { + if (self::$applicationFresh) { + self::$applicationFresh = false; + + return; + } + + if ($this->shouldResetApplication()) { + self::application()->resetState(); + } + } + + protected function shouldResetApplication(): bool + { + return true; + } + + protected static function browserRuntime(): BrowserRuntime|null + { + return self::$application?->browserRuntime(); + } + + protected static function application(): ApplicationInterface + { + if (!self::$application) { + throw new \LogicException('The application has not been configured yet.'); + } + + return self::$application; + } + + #[After] + protected function finishApplicationTracing(): void + { + if ('always' === PlaywrightManager::traceMode()) { + $this->writeBrowserTraces(); + } + } + + /** + * @throws \Throwable + */ + protected function onNotSuccessfulTest(\Throwable $t): never + { + if ('on-failure' === PlaywrightManager::traceMode()) { + $this->writeBrowserTraces(); + } + + parent::onNotSuccessfulTest($t); + } + + private static function currentBrowserPage(): PageInterface + { + return self::browserRuntime()?->currentPage() ?? throw new \LogicException('The browser runtime has not been created yet.'); + } + + private function writeBrowserTraces(): void + { + static $count = 0; + + foreach (self::browserRuntime()?->finishTracing(static::class.'-'.++$count) ?? [] as $path) { + fwrite(STDERR, \sprintf("\nPlaywright trace: %s\nOpen it with: npx playwright show-trace %s\n", $path, escapeshellarg($path))); + } + } +} diff --git a/e2e-testing/src/Browser/BrowserRuntime.php b/e2e-testing/src/Browser/BrowserRuntime.php new file mode 100644 index 0000000..abe23e0 --- /dev/null +++ b/e2e-testing/src/Browser/BrowserRuntime.php @@ -0,0 +1,90 @@ + + */ + private array $sessions = []; + + private BrowserSession|null $currentBrowser = null; + + public function __construct( + private readonly string $traceDirectory, + private readonly BrowserSessionFactoryInterface $sessionFactory = new PlaywrightManager(), + ) { + } + + public function createBrowser(string $baseUri, BrowserType $type = BrowserType::Firefox, BrowserOptions|null $options = null): BrowserSession + { + $browser = $this->sessionFactory->create($type, $baseUri, $options ?? BrowserOptions::create()); + $this->sessions[] = $browser; + $this->currentBrowser = $browser; + + return $browser; + } + + public function currentPage(): PageInterface + { + if (!$this->currentBrowser) { + throw new \LogicException('Create a Playwright browser before using selector assertions.'); + } + + return $this->currentBrowser->page(); + } + + /** + * Finishes the traces of all open browser sessions and writes them to traces. + * + * @return list + */ + public function finishTracing(string $name): array + { + if (!$this->sessions) { + return []; + } + + $name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '-', $name), '-'); + $paths = []; + (new Filesystem())->mkdir($this->traceDirectory); + + foreach ($this->sessions as $i => $session) { + $paths[] = $path = Path::join($this->traceDirectory, $name.($i ? '-'.($i + 1) : '').'.zip'); + $session->context()->tracing()->stop(['path' => $path]); + } + + return $paths; + } + + public function reset(): void + { + foreach ($this->sessions as $browser) { + $browser->close(); + } + + $this->sessions = []; + $this->currentBrowser = null; + } + + public function close(): void + { + $this->reset(); + $this->sessionFactory->close(); + } +} diff --git a/e2e-testing/src/Browser/BrowserSessionFactoryInterface.php b/e2e-testing/src/Browser/BrowserSessionFactoryInterface.php new file mode 100644 index 0000000..bc4a2ce --- /dev/null +++ b/e2e-testing/src/Browser/BrowserSessionFactoryInterface.php @@ -0,0 +1,20 @@ +dockerServices(); + return static::createApplicationConfig()->dockerServices(); } } diff --git a/e2e-testing/src/ManagedEdition/ManagedEdition.php b/e2e-testing/src/ManagedEdition/ManagedEdition.php index 24145db..92cd29b 100644 --- a/e2e-testing/src/ManagedEdition/ManagedEdition.php +++ b/e2e-testing/src/ManagedEdition/ManagedEdition.php @@ -12,8 +12,10 @@ namespace Contao\E2eTesting\ManagedEdition; +use Contao\E2eTesting\Application\ApplicationInterface; use Contao\E2eTesting\Browser\BackendBrowser; use Contao\E2eTesting\Browser\BrowserOptions; +use Contao\E2eTesting\Browser\BrowserRuntime; use Contao\E2eTesting\Browser\BrowserSession; use Contao\E2eTesting\Browser\BrowserType; use Contao\E2eTesting\Browser\PlaywrightManager; @@ -25,23 +27,17 @@ use Contao\E2eTesting\Http\ServerProcess; use Contao\InstallationRecipe\Fixture\FixtureResult; use Contao\InstallationRecipe\Fixture\FixtureSet; -use Playwright\Page\PageInterface; use Symfony\Component\BrowserKit\HttpBrowser; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\ResponseInterface; -final class ManagedEdition +final class ManagedEdition implements ApplicationInterface { private ServerProcess|null $server = null; - /** - * @var list - */ - private array $browserSessions = []; - - private BrowserSession|null $currentBrowser = null; + private readonly BrowserRuntime $browserRuntime; private string|null $preparedFixtureFingerprint = null; @@ -50,8 +46,12 @@ final class ManagedEdition public function __construct( private readonly ManagedEditionState $state, private readonly ServerManager $serverManager = new ServerManager(), - private readonly PlaywrightManager $playwrightManager = new PlaywrightManager(), + PlaywrightManager $playwrightManager = new PlaywrightManager(), ) { + $this->browserRuntime = new BrowserRuntime( + Path::join($this->state->config->environment->cache->rootDirectory, 'traces'), + $playwrightManager, + ); } public function __destruct() @@ -101,9 +101,15 @@ public function prepareDatabase(FixtureSet $fixtures): FixtureResult return $result; } + public function resetState(): void + { + $this->resetDatabase(); + } + public function resetRuntime(): void { - $this->resetRuntimeState(); + $this->browserRuntime->reset(); + $this->clearMutableRuntime(); } public function synchronizeFiles(string ...$paths): void @@ -169,77 +175,30 @@ public function createHttpBrowser(Origin $origin): HttpBrowser return $browser; } - public function createBrowser(BrowserType $type = BrowserType::Firefox, Origin|null $origin = null, BrowserOptions|null $options = null): BrowserSession + public function createBrowser(BrowserType $type = BrowserType::Firefox, BrowserOptions|null $options = null, Origin|null $origin = null): BrowserSession { - $options ??= BrowserOptions::create(); - $browser = $this->playwrightManager->create($type, $this->browserUri($origin), $options); - $this->browserSessions[] = $browser; - $this->currentBrowser = $browser; - - return $browser; - } - - public function createBackendBrowser(BrowserType $type = BrowserType::Firefox, Origin|null $origin = null, BrowserOptions|null $options = null): BackendBrowser - { - return new BackendBrowser($this->createBrowser($type, $origin, $options)); + return $this->browserRuntime->createBrowser($this->browserUri($origin), $type, $options); } - public function currentPage(): PageInterface + public function createBackendBrowser(BrowserType $type = BrowserType::Firefox, BrowserOptions|null $options = null, Origin|null $origin = null): BackendBrowser { - if (!$this->currentBrowser) { - throw new \LogicException('Create a Playwright browser before using selector assertions.'); - } - - return $this->currentBrowser->page(); + return new BackendBrowser($this->createBrowser($type, $options, $origin)); } - /** - * Finishes the traces of all open browser sessions and writes them to traces. - * - * @return list - */ - public function finishTracing(string $name): array + public function browserRuntime(): BrowserRuntime { - $name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '-', $name), '-'); - $directory = Path::join($this->state->config->environment->cache->rootDirectory, 'traces'); - $paths = []; - - (new Filesystem())->mkdir($directory); - - foreach ($this->browserSessions as $i => $session) { - $paths[] = $path = Path::join($directory, $name.($i ? '-'.($i + 1) : '').'.zip'); - $session->context()->tracing()->stop(['path' => $path]); - } - - return $paths; + return $this->browserRuntime; } public function release(): void { - $this->closeBrowserSessions(); - $this->playwrightManager->close(); + $this->browserRuntime->close(); $this->server?->stop(); $this->server = null; $this->database()->close(); $this->state->installation->lease->release(); } - private function resetRuntimeState(): void - { - $this->closeBrowserSessions(); - $this->clearMutableRuntime(); - } - - private function closeBrowserSessions(): void - { - foreach ($this->browserSessions as $browser) { - $browser->close(); - } - - $this->browserSessions = []; - $this->currentBrowser = null; - } - private function registerOrigin(Origin $origin): string { $alias = 'contao-e2e-'.substr(hash('sha256', $origin->host."\0".(int) $origin->https), 0, 16); diff --git a/e2e-testing/src/ManagedEdition/ManagedEditionConfig.php b/e2e-testing/src/ManagedEdition/ManagedEditionConfig.php index a642ea4..63a0cf9 100644 --- a/e2e-testing/src/ManagedEdition/ManagedEditionConfig.php +++ b/e2e-testing/src/ManagedEdition/ManagedEditionConfig.php @@ -12,6 +12,7 @@ namespace Contao\E2eTesting\ManagedEdition; +use Contao\E2eTesting\Application\ApplicationConfigInterface; use Contao\E2eTesting\Cache\CacheConfig; use Contao\E2eTesting\Database\DatabaseResetMode; use Contao\E2eTesting\Database\DatabaseServerConfig; @@ -21,7 +22,7 @@ use Contao\InstallationRecipe\File\FileMapping; use Contao\InstallationRecipe\Recipe\InstallationRecipe; -final readonly class ManagedEditionConfig +final readonly class ManagedEditionConfig implements ApplicationConfigInterface { private function __construct( public InstallationRecipe $recipe, @@ -39,6 +40,11 @@ public static function create(InstallationRecipe $recipe, string $projectDirecto )); } + public function createApplication(): ManagedEdition + { + return (new ManagedEditionFactory())->create($this)->startServer(); + } + public function withEnvironment(ManagedEditionEnvironment $environment): self { return new self($this->recipe, $environment, $this->resetMode, $this->appEnvironment); diff --git a/e2e-testing/src/ManagedEdition/ManagedEditionTestTrait.php b/e2e-testing/src/ManagedEdition/ManagedEditionTestTrait.php index 4db88a9..fc2a70f 100644 --- a/e2e-testing/src/ManagedEdition/ManagedEditionTestTrait.php +++ b/e2e-testing/src/ManagedEdition/ManagedEditionTestTrait.php @@ -12,109 +12,22 @@ namespace Contao\E2eTesting\ManagedEdition; -use Contao\E2eTesting\Browser\PlaywrightManager; -use PHPUnit\Framework\Assert; -use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\AfterClass; -use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\BeforeClass; -use Playwright\Assertions\AssertionOptions; -use Playwright\Assertions\Expect; +use Contao\E2eTesting\Application\ApplicationTestTrait; trait ManagedEditionTestTrait { - private static ManagedEdition|null $contaoManagedEdition = null; + use ApplicationTestTrait; - private static bool $contaoManagedEditionFresh = false; - - #[BeforeClass] - public static function createContaoManagedEdition(): void - { - self::$contaoManagedEdition = (new ManagedEditionFactory())->create(static::createManagedEditionConfig()); - self::$contaoManagedEdition->startServer(); - - self::$contaoManagedEditionFresh = true; - } - - #[AfterClass] - public static function releaseContaoManagedEdition(): void - { - self::$contaoManagedEdition?->release(); - self::$contaoManagedEdition = null; - self::$contaoManagedEditionFresh = false; - } - - public function assertSelectorExists(string $selector, string $message = ''): void - { - $locator = self::managedEdition()->currentPage()->locator($selector); - Expect::locator($locator->first())->toBeAttached(new AssertionOptions(message: $message ?: null)); - Assert::assertGreaterThan(0, $locator->count(), $message); - } - - public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void - { - $locator = self::managedEdition()->currentPage()->locator($selector); - Expect::locator($locator)->toContainText($text, new AssertionOptions(message: $message ?: null)); - Assert::assertGreaterThan(0, $locator->count(), $message); - Assert::assertStringContainsString($text, $locator->first()->innerText(), $message); - } - - abstract protected static function createManagedEditionConfig(): ManagedEditionConfig; - - #[Before] - protected function resetContaoManagedEdition(): void - { - if (self::$contaoManagedEditionFresh) { - self::$contaoManagedEditionFresh = false; - - return; - } - - if ($this->shouldResetContaoManagedEdition()) { - self::managedEdition()->resetDatabase(); - } - } - - #[After] - protected function finishContaoTracing(): void - { - if ('always' === PlaywrightManager::traceMode()) { - $this->writeContaoTraces(); - } - } - - /** - * @throws \Throwable - */ - protected function onNotSuccessfulTest(\Throwable $t): never - { - if ('on-failure' === PlaywrightManager::traceMode()) { - $this->writeContaoTraces(); - } - - parent::onNotSuccessfulTest($t); - } - - protected function shouldResetContaoManagedEdition(): bool - { - return true; - } + abstract protected static function createApplicationConfig(): ManagedEditionConfig; protected static function managedEdition(): ManagedEdition { - if (!self::$contaoManagedEdition) { - throw new \LogicException('The managed Contao edition has not been created yet.'); - } + $application = self::application(); - return self::$contaoManagedEdition; - } - - private function writeContaoTraces(): void - { - static $count = 0; - - foreach (self::$contaoManagedEdition?->finishTracing(static::class.'-'.++$count) ?? [] as $path) { - fwrite(STDERR, \sprintf("\nPlaywright trace: %s\nOpen it with: npx playwright show-trace %s\n", $path, escapeshellarg($path))); + if (!$application instanceof ManagedEdition) { + throw new \LogicException('The application is not a managed Contao edition.'); } + + return $application; } } diff --git a/e2e-testing/tests/ApplicationConfigTest.php b/e2e-testing/tests/ApplicationConfigTest.php new file mode 100644 index 0000000..11a9f5c --- /dev/null +++ b/e2e-testing/tests/ApplicationConfigTest.php @@ -0,0 +1,54 @@ +withTraceDirectory('/tmp/project-traces'); + + $this->assertNotSame($original, $configured); + $this->assertSame('https://example.test/app', $configured->baseUri); + $this->assertSame('.contao-e2e/traces', $original->traceDirectory()); + $this->assertSame('/tmp/project-traces', $configured->traceDirectory()); + } + + #[DataProvider('invalidUris')] + public function testRejectsInvalidBaseUris(string $uri): void + { + $this->expectException(\InvalidArgumentException::class); + ApplicationConfig::create($uri); + } + + public static function invalidUris(): iterable + { + yield ['']; + yield ['/relative']; + yield ['ftp://example.test']; + yield ['https://']; + yield ['https://example.test/?query=1']; + yield ['https://example.test/#fragment']; + } + + public function testRejectsAnEmptyTraceDirectory(): void + { + $this->expectException(\InvalidArgumentException::class); + ApplicationConfig::create('http://localhost:8080')->withTraceDirectory(' '); + } +} diff --git a/e2e-testing/tests/ApplicationTest.php b/e2e-testing/tests/ApplicationTest.php new file mode 100644 index 0000000..f6cad79 --- /dev/null +++ b/e2e-testing/tests/ApplicationTest.php @@ -0,0 +1,64 @@ +createStub(PageInterface::class); + $context = $this->createMock(BrowserContextInterface::class); + $context + ->expects($this->once()) + ->method('close') + ; + $session = new BrowserSession('http://localhost:8080', $context, $page); + $options = BrowserOptions::create()->withAcceptLanguage('de-CH'); + $factory = $this->createMock(BrowserSessionFactoryInterface::class); + $factory + ->expects($this->once()) + ->method('create') + ->with(BrowserType::Firefox, 'http://localhost:8080', $options) + ->willReturn($session) + ; + + $factory + ->expects($this->once()) + ->method('close') + ; + $application = new Application(ApplicationConfig::create('http://localhost:8080'), new BrowserRuntime('/unused', $factory)); + + $backend = $application->createBackendBrowser(options: $options); + $this->assertSame($session, $backend->browser()); + $this->assertSame($page, $application->browserRuntime()->currentPage()); + $application->resetState(); + $this->expectException(\LogicException::class); + + try { + $application->browserRuntime()->currentPage(); + } finally { + $application->release(); + } + } +} diff --git a/e2e-testing/tests/ApplicationTestTraitTest.php b/e2e-testing/tests/ApplicationTestTraitTest.php new file mode 100644 index 0000000..a99cc2d --- /dev/null +++ b/e2e-testing/tests/ApplicationTestTraitTest.php @@ -0,0 +1,73 @@ +assertInstanceOf(Application::class, self::application()); + $this->assertSame([], self::application()->browserRuntime()->finishTracing('empty')); + } + + public function testDelegatesInterTestResetToTheConfiguredApplication(): void + { + $application = $this->createMock(ApplicationInterface::class); + $application + ->expects($this->once()) + ->method('resetState') + ; + + $application + ->expects($this->once()) + ->method('release') + ; + self::releaseApplication(); + self::$configuredApplication = $application; + self::createApplication(); + + try { + $this->resetApplication(); + $this->resetApplication(); + } finally { + self::releaseApplication(); + self::$configuredApplication = null; + self::createApplication(); + } + } + + protected static function createApplicationConfig(): ApplicationConfigInterface + { + return new class(self::$configuredApplication) implements ApplicationConfigInterface { + public function __construct(private readonly ApplicationInterface|null $application) + { + } + + public function createApplication(): ApplicationInterface + { + return $this->application ?? ApplicationConfig::create('http://localhost:8080')->createApplication(); + } + }; + } +} diff --git a/e2e-testing/tests/BrowserRuntimeTest.php b/e2e-testing/tests/BrowserRuntimeTest.php new file mode 100644 index 0000000..4b0a4ac --- /dev/null +++ b/e2e-testing/tests/BrowserRuntimeTest.php @@ -0,0 +1,130 @@ +createMock(BrowserSessionFactoryInterface::class); + $firstPage = $this->createStub(PageInterface::class); + $secondPage = $this->createStub(PageInterface::class); + $firstContext = $this->createMock(BrowserContextInterface::class); + $secondContext = $this->createMock(BrowserContextInterface::class); + $firstContext + ->expects($this->once()) + ->method('close') + ; + + $secondContext + ->expects($this->once()) + ->method('close') + ; + + $factory + ->expects($this->exactly(2)) + ->method('create') + ->willReturnOnConsecutiveCalls( + new BrowserSession('https://example.test', $firstContext, $firstPage), + new BrowserSession('https://example.test', $secondContext, $secondPage), + ) + ; + + $factory + ->expects($this->once()) + ->method('close') + ; + $runtime = new BrowserRuntime('/unused', $factory); + + $runtime->createBrowser('https://example.test'); + $this->assertSame($firstPage, $runtime->currentPage()); + $runtime->createBrowser('https://example.test'); + $this->assertSame($secondPage, $runtime->currentPage()); + $runtime->reset(); + $runtime->close(); + + $this->expectException(\LogicException::class); + $runtime->currentPage(); + } + + public function testForwardsTheUrlEngineAndOptions(): void + { + $options = BrowserOptions::create()->withViewport(800, 600); + $session = new BrowserSession('https://example.test/app', $this->createStub(BrowserContextInterface::class), $this->createStub(PageInterface::class)); + $factory = $this->createMock(BrowserSessionFactoryInterface::class); + $factory + ->expects($this->once()) + ->method('create') + ->with(BrowserType::Chromium, 'https://example.test/app', $options) + ->willReturn($session) + ; + $runtime = new BrowserRuntime('/unused', $factory); + + $this->assertSame($session, $runtime->createBrowser('https://example.test/app', BrowserType::Chromium, $options)); + $this->assertSame('https://example.test/app/login', $session->uri('/login')); + } + + public function testWritesDistinctSanitizedTracePathsForMultipleSessions(): void + { + $directory = sys_get_temp_dir().'/browser-runtime-'.bin2hex(random_bytes(6)); + $factory = $this->createMock(BrowserSessionFactoryInterface::class); + $factory + ->expects($this->exactly(2)) + ->method('create') + ->willReturn( + $this->tracedSession($directory.'/Example-test.zip'), + $this->tracedSession($directory.'/Example-test-2.zip'), + ) + ; + $runtime = new BrowserRuntime($directory, $factory); + + try { + $runtime->createBrowser('https://example.test'); + $runtime->createBrowser('https://example.test'); + $this->assertSame([$directory.'/Example-test.zip', $directory.'/Example-test-2.zip'], $runtime->finishTracing('Example::test')); + $runtime->reset(); + $this->assertSame([], $runtime->finishTracing('Empty')); + } finally { + $runtime->close(); + (new Filesystem())->remove($directory); + } + } + + private function tracedSession(string $path): BrowserSession + { + $tracing = $this->createMock(TracingInterface::class); + $tracing + ->expects($this->once()) + ->method('stop') + ->with(['path' => $path]) + ; + $context = $this->createStub(BrowserContextInterface::class); + $context + ->method('tracing') + ->willReturn($tracing) + ; + + return new BrowserSession('https://example.test', $context, $this->createStub(PageInterface::class)); + } +} diff --git a/e2e-testing/tests/Fixture/AbstractManagedEditionTestCase.php b/e2e-testing/tests/Fixture/AbstractManagedEditionTestCase.php index 7078e4d..dd936f6 100644 --- a/e2e-testing/tests/Fixture/AbstractManagedEditionTestCase.php +++ b/e2e-testing/tests/Fixture/AbstractManagedEditionTestCase.php @@ -20,7 +20,7 @@ abstract class AbstractManagedEditionTestCase extends BaseManagedEditionTestCase { - protected static function createManagedEditionConfig(): ManagedEditionConfig + protected static function createApplicationConfig(): ManagedEditionConfig { return ManagedEditionConfig::create( InstallationRecipe::create(ComposerConfig::managedEdition('^5.7')), From 26cbe7deddc3a4971dc0802a0d5ca4ad9ec157fd Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Fri, 2 Oct 2026 10:53:04 +0200 Subject: [PATCH 2/2] Windows party --- e2e-testing/tests/BrowserRuntimeTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e-testing/tests/BrowserRuntimeTest.php b/e2e-testing/tests/BrowserRuntimeTest.php index 4b0a4ac..28ecc03 100644 --- a/e2e-testing/tests/BrowserRuntimeTest.php +++ b/e2e-testing/tests/BrowserRuntimeTest.php @@ -22,6 +22,7 @@ use Playwright\Page\PageInterface; use Playwright\Tracing\TracingInterface; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; class BrowserRuntimeTest extends TestCase { @@ -87,7 +88,7 @@ public function testForwardsTheUrlEngineAndOptions(): void public function testWritesDistinctSanitizedTracePathsForMultipleSessions(): void { - $directory = sys_get_temp_dir().'/browser-runtime-'.bin2hex(random_bytes(6)); + $directory = Path::join(sys_get_temp_dir(), 'browser-runtime-'.bin2hex(random_bytes(6))); $factory = $this->createMock(BrowserSessionFactoryInterface::class); $factory ->expects($this->exactly(2))