Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The dependency goes from `contao/e2e-testing` to `contao/installation-recipe`. T
| Setting | Purpose |
| --- | --- |
| `monorepo_url` | The Git remote for this source repository. |
| `branch_filter` | The default branches eligible for splitting: `main` and numeric release branches such as `1.0`. |
| `branch_filter` | The branches eligible for splitting: `main`, numeric release branches such as `1.0`, and `feature/*`. |
| `repositories` | Maps each package directory to its split repository: [`e2e-testing`](https://github.com/contao/e2e-testing) and [`installation-recipe`](https://github.com/contao/installation-recipe). |
| `composer` | Extra settings for the combined root `composer.json`. Here, `bamarni/composer-bin-plugin` is a root development dependency. The empty `require` and `conflict` lists add no constraints. |

Expand Down
72 changes: 71 additions & 1 deletion e2e-testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,76 @@ vendor/bin/playwright-install --browsers

Use `vendor/bin/playwright-install --with-deps` on a fresh Linux CI runner to install the required system libraries as well. Playwright caches matching Chromium, Firefox, and WebKit binaries outside the project and reuses them between runs.

## CI caches

`cache:metadata` writes separate portable keys for Playwright browser binaries and reusable E2E setup data, then prints each opaque fingerprint and cache root as JSON:

```shell
vendor/bin/contao-e2e cache:metadata
```

The keys are written to `.contao-e2e/cache-keys/playwright` and `.contao-e2e/cache-keys/e2e`. Any CI system can use their contents directly or hash the files. They remain separate because browser binaries and the rest of the E2E setup have different invalidation rules.

The Playwright fingerprint uses the concrete version from the installed Node package, the browser revisions from Playwright's installed browser registry, operating system, OS release or Linux distribution version, and architecture. The E2E fingerprint covers the PHP major and minor version, operating system, architecture, the installed `contao/e2e-testing` and `contao/installation-recipe` versions, and Composer settings that can affect dependency resolution.

The Playwright PHP package resolves the semver constraint in its bundled `package.json` through npm, pnpm, or Yarn. Its resolved Node package and browser registry must therefore exist before metadata can be calculated. Prepare those dependencies explicitly after Composer installation:

```shell
vendor/bin/playwright-install
vendor/bin/contao-e2e cache:metadata
```

The first command may access the network to install Node packages. It does not install browser binaries without `--browsers`. `cache:metadata` never performs this preparation or accesses the network itself.

A complete GitHub Actions job can keep every cache payload under `.contao-e2e/cache` and restore both groups independently:

```yaml
jobs:
e2e:
runs-on: ubuntu-latest
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.contao-e2e/cache/playwright
steps:
- uses: actions/checkout@v6

- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: intl, mbstring, pdo_mysql, zip
coverage: none

- name: Install Composer dependencies
run: composer install --no-interaction --no-progress

- name: Prepare Playwright Node dependencies
run: vendor/bin/playwright-install

- name: Calculate E2E cache metadata
run: vendor/bin/contao-e2e cache:metadata

- name: Restore Playwright browsers
uses: actions/cache@v4
with:
path: .contao-e2e/cache/playwright
key: playwright-${{ hashFiles('.contao-e2e/cache-keys/playwright') }}

- name: Restore E2E setup cache
uses: actions/cache@v4
with:
path: .contao-e2e/cache/e2e
key: contao-e2e-${{ hashFiles('.contao-e2e/cache-keys/e2e') }}

- name: Install and verify Playwright browsers
run: vendor/bin/playwright-install --browsers

- name: Run PHPUnit
run: vendor/bin/phpunit --configuration=phpunit.xml.dist
```

The cache root contains separate `playwright` and `e2e` groups. The package owns the contents of each group, so adding another reusable E2E setup cache does not require consuming projects to update their CI configuration. Database data, process locks, runtime files, and failure artifacts are deliberately excluded. The existing per-installation dependency and application fingerprints still validate restored installations, so project source files do not need to be part of the outer CI cache key.

GitHub Actions restricts cache access by branch and ref. A pull request can restore caches created on its base branch, while caches created for a pull request's merge ref are only available to reruns of that pull request. Run this job on pushes to the default branch as well as pull requests so the default branch regularly creates a cache that different pull requests can reuse.

### Test a bundle from its working tree

The following example lives in a Contao bundle repository, not in this library. Install `contao/e2e-testing` as a development dependency as shown above, then put this test in `tests/E2e/ManagedEditionSmokeTest.php`. Replace `acme/example-bundle` with the `name` from your bundle's `composer.json` and choose a version that satisfies its Composer constraints. The version does not have to match the name of your current Git branch.
Expand Down Expand Up @@ -375,7 +445,7 @@ $this->assertSame(200, $browser->getInternalResponse()->getStatusCode());
$this->assertSame('Example', trim($crawler->filterXPath('//head/title')->text()));
```

Full Managed Editions are stored below `.contao-e2e/cache/installations/<fingerprint>/<slot>/project`. The matching
Full Managed Editions are stored below `.contao-e2e/cache/e2e/installations/<fingerprint>/<slot>/project`. The matching
MySQL or MariaDB database runs in the configured server or a reusable Docker container. The default database files are stored below `.contao-e2e/database/data`; additional image variants use `.contao-e2e/database/<fingerprint>/data`. The `runtime/` directory only contains
the lightweight webserver router and origin mapping.

Expand Down
35 changes: 35 additions & 0 deletions e2e-testing/src/Cache/CacheConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,39 @@ public function withRootDirectory(string $rootDirectory): self
{
return new self($this->projectDirectory, Path::makeAbsolute($rootDirectory, $this->projectDirectory));
}

public function cacheDirectory(): string
{
return Path::join($this->rootDirectory, 'cache');
}

public function e2eCacheDirectory(): string
{
return Path::join($this->cacheDirectory(), 'e2e');
}

public function composerCacheDirectory(): string
{
return Path::join($this->e2eCacheDirectory(), 'composer');
}

public function dependencyLocksDirectory(): string
{
return Path::join($this->e2eCacheDirectory(), 'dependency-locks');
}

public function installationsDirectory(): string
{
return Path::join($this->e2eCacheDirectory(), 'installations');
}

public function playwrightCacheDirectory(): string
{
return Path::join($this->cacheDirectory(), 'playwright');
}

public function cacheKeysDirectory(): string
{
return Path::join($this->rootDirectory, 'cache-keys');
}
}
249 changes: 249 additions & 0 deletions e2e-testing/src/Cache/CacheMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
<?php

declare(strict_types=1);

/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/

namespace Contao\E2eTesting\Cache;

use Composer\InstalledVersions;
use Contao\E2eTesting\Exception\E2eTestException;
use Symfony\Component\Filesystem\Path;

final readonly class CacheMetadataFactory
{
/**
* @param array<string, mixed> $compatibilityOverrides
*/
public function __construct(
private string|null $playwrightPackageDirectory = null,
private array $compatibilityOverrides = [],
private string|null $operatingSystem = null,
private string|null $architecture = null,
private string|null $operatingSystemVersion = null,
) {
}

/**
* @return array{
* schema_version: int,
* playwright: array{fingerprint: string, path: string},
* e2e: array{fingerprint: string, path: string}
* }
*/
public function create(CacheConfig $config): array
{
$compatibility = array_replace_recursive($this->compatibility(), $this->compatibilityOverrides);

return [
'schema_version' => 1,
'playwright' => [
'fingerprint' => $this->playwrightFingerprint(),
'path' => $config->playwrightCacheDirectory(),
],
'e2e' => [
'fingerprint' => $this->hash($compatibility),
'path' => $config->e2eCacheDirectory(),
],
];
}

private function playwrightFingerprint(): string
{
$playwrightDirectory = realpath(Path::join($this->packageDirectory(), 'bin/node_modules/playwright'));

if (false === $playwrightDirectory) {
throw new E2eTestException('Playwright Node dependencies are not prepared. Run "vendor/bin/playwright-install" before "vendor/bin/contao-e2e cache:metadata". This preparation may download Node packages.');
}

$coreDirectory = realpath(Path::join(\dirname($playwrightDirectory), 'playwright-core'));

if (false === $coreDirectory) {
throw new E2eTestException('Could not locate playwright-core in the prepared Playwright Node dependencies.');
}

$version = $this->readVersion(Path::join($playwrightDirectory, 'package.json'));
$browsers = $this->readBrowsers(Path::join($coreDirectory, 'browsers.json'));
$platform = [
'operating_system' => $this->operatingSystem ?? PHP_OS_FAMILY,
'operating_system_version' => $this->operatingSystemVersion ?? $this->operatingSystemVersion(),
'architecture' => $this->architecture ?? php_uname('m'),
];

return $this->hash([$version, $browsers, $platform]);
}

private function operatingSystemVersion(): string
{
if ('Linux' === PHP_OS_FAMILY && is_readable('/etc/os-release')) {
$release = parse_ini_file('/etc/os-release', scanner_mode: INI_SCANNER_RAW);

if (false !== $release && isset($release['ID'], $release['VERSION_ID'])) {
return $release['ID'].'-'.$release['VERSION_ID'];
}
}

return php_uname('r');
}

private function packageDirectory(): string
{
if (null !== $this->playwrightPackageDirectory) {
return $this->playwrightPackageDirectory;
}

$directory = InstalledVersions::getInstallPath('playwright-php/playwright');

if (null === $directory) {
throw new E2eTestException('Could not locate the installed playwright-php/playwright package.');
}

return Path::canonicalize($directory);
}

private function readVersion(string $path): string
{
$package = $this->readJson($path);
$version = $package['version'] ?? null;

if (!\is_string($version) || '' === $version) {
throw new E2eTestException(\sprintf('The installed Playwright package "%s" has no version.', $path));
}

return $version;
}

/**
* @return array<string, array{revision: string, revision_overrides: array<string, string>}>
*/
private function readBrowsers(string $path): array
{
$document = $this->readJson($path);
$entries = $document['browsers'] ?? null;

if (!\is_array($entries)) {
throw new E2eTestException(\sprintf('The installed Playwright browser registry "%s" is invalid.', $path));
}

$browsers = [];

foreach ($entries as $entry) {
if (\is_array($entry) && true === ($entry['installByDefault'] ?? false)) {
$this->addBrowser($browsers, $entry, $path);
}
}

ksort($browsers);

return $browsers;
}

/**
* @param array<string, array{revision: string, revision_overrides: array<string, string>}> $browsers
* @param array<array-key, mixed> $entry
*/
private function addBrowser(array &$browsers, array $entry, string $path): void
{
$name = $entry['name'] ?? null;
$revision = $entry['revision'] ?? null;
$overrides = $entry['revisionOverrides'] ?? [];

if (!\is_string($name) || !\is_string($revision) || !\is_array($overrides)) {
throw new E2eTestException(\sprintf('The installed Playwright browser registry "%s" is invalid.', $path));
}

$revisionOverrides = [];

foreach ($overrides as $platform => $override) {
if (\is_string($platform) && \is_string($override)) {
$revisionOverrides[$platform] = $override;
}
}

ksort($revisionOverrides);
$browsers[$name] = ['revision' => $revision, 'revision_overrides' => $revisionOverrides];
}

/**
* @return array<string, mixed>
*/
private function readJson(string $path): array
{
if (!is_file($path)) {
throw new E2eTestException(\sprintf('Could not find the installed Playwright metadata "%s".', $path));
}

$contents = file_get_contents($path);

if (false === $contents) {
throw new E2eTestException(\sprintf('Could not read the installed Playwright metadata "%s".', $path));
}

return json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
}

/**
* @return array<string, mixed>
*/
private function compatibility(): array
{
return [
'php' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'operating_system' => $this->operatingSystem ?? PHP_OS_FAMILY,
'architecture' => $this->architecture ?? php_uname('m'),
'packages' => [
'contao/e2e-testing' => $this->packageVersion('contao/e2e-testing'),
'contao/installation-recipe' => $this->packageVersion('contao/installation-recipe'),
],
'composer' => $this->composerConfiguration(),
];
}

private function packageVersion(string $package): string
{
$version = InstalledVersions::getVersion($package);

if (null !== $version) {
return $version.'@'.(InstalledVersions::getReference($package) ?? 'unknown');
}

$root = InstalledVersions::getRootPackage();

return ($root['version'] ?? 'unknown').'@'.($root['reference'] ?? 'unknown');
}

/**
* @return array<string, string|null>
*/
private function composerConfiguration(): array
{
$configuration = [];

foreach ([
'COMPOSER_IGNORE_PLATFORM_REQ',
'COMPOSER_IGNORE_PLATFORM_REQS',
'COMPOSER_MINIMAL_CHANGES',
'COMPOSER_MIRROR_PATH_REPOS',
'COMPOSER_PREFER_LOWEST',
'COMPOSER_PREFER_STABLE',
'COMPOSER_WITH_ALL_DEPENDENCIES',
'COMPOSER_WITH_DEPENDENCIES',
] as $name) {
$value = getenv($name);
$configuration[$name] = false === $value ? null : $value;
}

return $configuration;
}

private function hash(mixed $value): string
{
return hash('sha256', serialize($value));
}
}
2 changes: 1 addition & 1 deletion e2e-testing/src/Cache/WorkspaceCleaner.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function __construct(private Filesystem $filesystem = new Filesystem())
public function clearCache(CacheConfig $config): void
{
$this->assertManaged($config);
$this->filesystem->remove(Path::join($config->rootDirectory, 'cache'));
$this->filesystem->remove([$config->cacheDirectory(), $config->cacheKeysDirectory()]);
(new WorkspaceInitializer($this->filesystem))->initialize($config);
}

Expand Down
Loading
Loading