From 659f8d0853a5467377a2a35d2a79e3d26591c545 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 30 Sep 2026 23:37:27 +0200 Subject: [PATCH 1/9] Add CI cache metadata command --- e2e-testing/README.md | 73 +++++ e2e-testing/src/Cache/CacheConfig.php | 17 + .../src/Cache/CacheMetadataFactory.php | 295 ++++++++++++++++++ .../src/Cache/FingerprintCalculator.php | 17 +- e2e-testing/src/Cache/ValueFingerprint.php | 21 ++ .../src/Cache/WorkspaceInitializer.php | 6 +- .../src/Command/CacheMetadataCommand.php | 62 ++++ e2e-testing/src/Command/E2eApplication.php | 1 + .../src/Command/GithubOutputWriter.php | 50 +++ .../src/Composer/ComposerInstaller.php | 2 +- .../src/Installation/InstallationPool.php | 2 +- .../tests/CacheMetadataCommandTest.php | 123 ++++++++ .../tests/CacheMetadataFactoryTest.php | 165 ++++++++++ e2e-testing/tests/E2eApplicationTest.php | 1 + 14 files changed, 820 insertions(+), 15 deletions(-) create mode 100644 e2e-testing/src/Cache/CacheMetadataFactory.php create mode 100644 e2e-testing/src/Cache/ValueFingerprint.php create mode 100644 e2e-testing/src/Command/CacheMetadataCommand.php create mode 100644 e2e-testing/src/Command/GithubOutputWriter.php create mode 100644 e2e-testing/tests/CacheMetadataCommandTest.php create mode 100644 e2e-testing/tests/CacheMetadataFactoryTest.php diff --git a/e2e-testing/README.md b/e2e-testing/README.md index 3f8e23b..e924e0e 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -107,6 +107,79 @@ 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` exposes separate cache fingerprints and paths for Playwright browser binaries and Managed Edition dependencies: + +```shell +vendor/bin/contao-e2e cache:metadata +``` + +The command prints stable JSON containing `playwright` and `managed_edition` objects. The Playwright fingerprint uses the concrete version from the installed Node package and the browser revisions from Playwright's installed browser registry. The Managed Edition fingerprint covers the cache format, 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` only reads installed metadata and never performs this preparation itself. + +Pass `--github-output` to also write cache keys and paths to the file in `GITHUB_OUTPUT`. JSON remains on standard output and has no GitHub Actions-specific encoding. Multiline paths use GitHub Actions' delimiter format: + +```shell +vendor/bin/contao-e2e cache:metadata --github-output +``` + +A complete GitHub Actions job can restore both caches independently: + +```yaml +jobs: + e2e: + runs-on: ubuntu-latest + 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 + id: e2e-cache + run: vendor/bin/contao-e2e cache:metadata --github-output + + - name: Restore Playwright browsers + uses: actions/cache@v4 + with: + path: ${{ steps.e2e-cache.outputs.playwright_path }} + key: playwright-${{ steps.e2e-cache.outputs.playwright_fingerprint }} + + - name: Restore Managed Edition caches + uses: actions/cache@v4 + with: + path: ${{ steps.e2e-cache.outputs.managed_edition_paths }} + key: contao-e2e-${{ steps.e2e-cache.outputs.managed_edition_fingerprint }} + + - name: Install and verify Playwright browsers + run: vendor/bin/playwright-install --browsers + + - name: Run PHPUnit + run: vendor/bin/phpunit --configuration=phpunit.xml.dist +``` + +The Managed Edition paths contain only `.contao-e2e/cache/composer`, `.contao-e2e/cache/dependency-locks`, and `.contao-e2e/cache/installations`. 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 Actions 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. diff --git a/e2e-testing/src/Cache/CacheConfig.php b/e2e-testing/src/Cache/CacheConfig.php index f51a52f..7bd0ace 100644 --- a/e2e-testing/src/Cache/CacheConfig.php +++ b/e2e-testing/src/Cache/CacheConfig.php @@ -16,6 +16,8 @@ final readonly class CacheConfig { + public const FORMAT_VERSION = 1; + private function __construct( public string $projectDirectory, public string $rootDirectory, @@ -37,4 +39,19 @@ public function withRootDirectory(string $rootDirectory): self { return new self($this->projectDirectory, Path::makeAbsolute($rootDirectory, $this->projectDirectory)); } + + public function composerCacheDirectory(): string + { + return Path::join($this->rootDirectory, 'cache/composer'); + } + + public function dependencyLocksDirectory(): string + { + return Path::join($this->rootDirectory, 'cache/dependency-locks'); + } + + public function installationsDirectory(): string + { + return Path::join($this->rootDirectory, 'cache/installations'); + } } diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php new file mode 100644 index 0000000..8dc6fb8 --- /dev/null +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -0,0 +1,295 @@ + $compatibilityOverrides + */ + public function __construct( + private string|null $playwrightPackageDirectory = null, + private string|null $browserDirectory = null, + private array $compatibilityOverrides = [], + private string|null $operatingSystem = null, + private string|null $architecture = null, + private ValueFingerprint $fingerprint = new ValueFingerprint(), + ) { + } + + /** + * @return array{ + * schema_version: int, + * playwright: array{fingerprint: string, path: string, version: string, browsers: array}>}, + * managed_edition: array{fingerprint: string, paths: array{composer: string, dependency_locks: string, installations: string}} + * } + */ + public function create(CacheConfig $config): array + { + $compatibility = array_replace_recursive($this->compatibility(), $this->compatibilityOverrides); + + return [ + 'schema_version' => 1, + 'playwright' => $this->playwrightMetadata(), + 'managed_edition' => [ + 'fingerprint' => $this->fingerprint->calculate($compatibility), + 'paths' => [ + 'composer' => $config->composerCacheDirectory(), + 'dependency_locks' => $config->dependencyLocksDirectory(), + 'installations' => $config->installationsDirectory(), + ], + ], + ]; + } + + /** + * @return array{ + * fingerprint: string, + * path: string, + * version: string, + * browsers: array}> + * } + */ + private function playwrightMetadata(): array + { + $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, + 'architecture' => $this->architecture ?? php_uname('m'), + ]; + + return [ + 'fingerprint' => $this->fingerprint->calculate([$version, $browsers, $platform]), + 'path' => $this->browserDirectory ?? $this->resolveBrowserDirectory($coreDirectory), + 'version' => $version, + 'browsers' => $browsers, + ]; + } + + 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}> + */ + 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}> $browsers + * @param array $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 + */ + 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); + } + + private function resolveBrowserDirectory(string $coreDirectory): string + { + $configured = getenv('PLAYWRIGHT_BROWSERS_PATH'); + + if ('0' === $configured) { + return Path::join($coreDirectory, '.local-browsers'); + } + + if (false !== $configured && '' !== $configured) { + $workingDirectory = getenv('INIT_CWD') ?: (string) getcwd(); + + return Path::makeAbsolute($configured, $workingDirectory); + } + + return Path::join($this->defaultCacheDirectory(), 'ms-playwright'); + } + + private function defaultCacheDirectory(): string + { + if ('Windows' === ($this->operatingSystem ?? PHP_OS_FAMILY)) { + return $this->environmentDirectory('LOCALAPPDATA') + ?? Path::join($this->homeDirectory(), 'AppData/Local'); + } + + if ('Darwin' === ($this->operatingSystem ?? PHP_OS_FAMILY)) { + return Path::join($this->homeDirectory(), 'Library/Caches'); + } + + return $this->environmentDirectory('XDG_CACHE_HOME') ?? Path::join($this->homeDirectory(), '.cache'); + } + + private function homeDirectory(): string + { + return $this->environmentDirectory('HOME') + ?? $this->environmentDirectory('USERPROFILE') + ?? throw new E2eTestException('Could not determine the Playwright browser cache directory.'); + } + + private function environmentDirectory(string $name): string|null + { + $value = getenv($name); + + return false === $value || '' === $value ? null : $value; + } + + /** + * @return array + */ + private function compatibility(): array + { + return [ + 'cache_format' => CacheConfig::FORMAT_VERSION, + '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 + */ + private function composerConfiguration(): array + { + $configuration = [ + 'executable' => 'composer', + 'prefer_lowest' => false, + 'prefer_stable' => true, + ]; + + 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; + } +} diff --git a/e2e-testing/src/Cache/FingerprintCalculator.php b/e2e-testing/src/Cache/FingerprintCalculator.php index 1fc94d9..bbf0a08 100644 --- a/e2e-testing/src/Cache/FingerprintCalculator.php +++ b/e2e-testing/src/Cache/FingerprintCalculator.php @@ -18,8 +18,10 @@ final readonly class FingerprintCalculator { - public function __construct(private SourceFingerprintInterface $sourceFingerprint = new ProcessCachedSourceFingerprint()) - { + public function __construct( + private SourceFingerprintInterface $sourceFingerprint = new ProcessCachedSourceFingerprint(), + private ValueFingerprint $valueFingerprint = new ValueFingerprint(), + ) { } public function calculate(ManagedEditionConfig $config): FingerprintSet @@ -27,7 +29,7 @@ public function calculate(ManagedEditionConfig $config): FingerprintSet $recipe = $config->recipe; $projectDirectory = $config->environment->cache->projectDirectory; $composer = $config->environment->composer; - $dependency = $this->hash([ + $dependency = $this->valueFingerprint->calculate([ $recipe->composer->toArray($projectDirectory), $this->hashFiles(array_map( static fn ($package) => Path::join($package->path, 'composer.json'), @@ -45,23 +47,18 @@ public function calculate(ManagedEditionConfig $config): FingerprintSet $sources[$package->package] = $this->sourceFingerprint->calculate($package->path); } - $application = $this->hash([ + $application = $this->valueFingerprint->calculate([ $dependency, $config->appEnvironment, $sources, $this->hashFiles(array_map(static fn ($fragment) => $fragment->path, $recipe->assets->configFragments)), $this->hashMappings($recipe->assets->fileMappings), ]); - $data = $this->hash([$application, $this->hashFiles($recipe->fixtures->files)]); + $data = $this->valueFingerprint->calculate([$application, $this->hashFiles($recipe->fixtures->files)]); return new FingerprintSet($dependency, $application, $data); } - private function hash(mixed $value): string - { - return hash('sha256', serialize($value)); - } - /** * @param list $files * diff --git a/e2e-testing/src/Cache/ValueFingerprint.php b/e2e-testing/src/Cache/ValueFingerprint.php new file mode 100644 index 0000000..a5d2432 --- /dev/null +++ b/e2e-testing/src/Cache/ValueFingerprint.php @@ -0,0 +1,21 @@ +assertSafe($config); $this->filesystem->mkdir([ - Path::join($config->rootDirectory, 'cache/composer'), - Path::join($config->rootDirectory, 'cache/dependency-locks'), - Path::join($config->rootDirectory, 'cache/installations'), + $config->composerCacheDirectory(), + $config->dependencyLocksDirectory(), + $config->installationsDirectory(), Path::join($config->rootDirectory, 'database'), Path::join($config->rootDirectory, 'failures'), Path::join($config->rootDirectory, 'locks'), diff --git a/e2e-testing/src/Command/CacheMetadataCommand.php b/e2e-testing/src/Command/CacheMetadataCommand.php new file mode 100644 index 0000000..531a81e --- /dev/null +++ b/e2e-testing/src/Command/CacheMetadataCommand.php @@ -0,0 +1,62 @@ +addOption('github-output', null, InputOption::VALUE_NONE, 'Also write values to the file named by GITHUB_OUTPUT'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $metadata = $this->metadataFactory->create($this->cache()); + $output->writeln(json_encode( + $metadata, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + )); + + if ($input->getOption('github-output')) { + $path = getenv('GITHUB_OUTPUT'); + + if (false === $path || '' === $path) { + throw new E2eTestException('The GITHUB_OUTPUT environment variable is required with --github-output.'); + } + + $this->githubOutputWriter->write($path, [ + 'playwright_fingerprint' => $metadata['playwright']['fingerprint'], + 'playwright_path' => $metadata['playwright']['path'], + 'managed_edition_fingerprint' => $metadata['managed_edition']['fingerprint'], + 'managed_edition_paths' => implode("\n", array_values($metadata['managed_edition']['paths'])), + ]); + } + + return self::SUCCESS; + } +} diff --git a/e2e-testing/src/Command/E2eApplication.php b/e2e-testing/src/Command/E2eApplication.php index 5461744..f77d181 100644 --- a/e2e-testing/src/Command/E2eApplication.php +++ b/e2e-testing/src/Command/E2eApplication.php @@ -23,6 +23,7 @@ public function __construct() $this->addCommands([ new DoctorCommand(), new CacheClearCommand(), + new CacheMetadataCommand(), new DatabaseStopCommand(), new FailuresClearCommand(), ]); diff --git a/e2e-testing/src/Command/GithubOutputWriter.php b/e2e-testing/src/Command/GithubOutputWriter.php new file mode 100644 index 0000000..542b51c --- /dev/null +++ b/e2e-testing/src/Command/GithubOutputWriter.php @@ -0,0 +1,50 @@ + $values + */ + public function write(string $path, array $values): void + { + $output = ''; + + foreach ($values as $name => $value) { + if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name)) { + throw new E2eTestException(\sprintf('The GitHub Actions output name "%s" is invalid.', $name)); + } + + $delimiter = $this->delimiter($name, $value); + $output .= $name.'<<'.$delimiter."\n".$value."\n".$delimiter."\n"; + } + + if (false === file_put_contents($path, $output, FILE_APPEND | LOCK_EX)) { + throw new E2eTestException(\sprintf('Could not write GitHub Actions outputs to "%s".', $path)); + } + } + + private function delimiter(string $name, string $value): string + { + $delimiter = 'CONTAO_E2E_'.strtoupper(substr(hash('sha256', $name."\0".$value), 0, 16)); + + while (\in_array($delimiter, preg_split('/\R/', $value) ?: [], true)) { + $delimiter .= '_X'; + } + + return $delimiter; + } +} diff --git a/e2e-testing/src/Composer/ComposerInstaller.php b/e2e-testing/src/Composer/ComposerInstaller.php index cebbd2a..1573d29 100644 --- a/e2e-testing/src/Composer/ComposerInstaller.php +++ b/e2e-testing/src/Composer/ComposerInstaller.php @@ -43,7 +43,7 @@ public function install(ManagedEditionConfig $config, string $directory): void $command, $directory, [ - 'COMPOSER_CACHE_DIR' => Path::join($config->environment->cache->rootDirectory, 'cache/composer'), + 'COMPOSER_CACHE_DIR' => $config->environment->cache->composerCacheDirectory(), ], ); } diff --git a/e2e-testing/src/Installation/InstallationPool.php b/e2e-testing/src/Installation/InstallationPool.php index 434c0c5..5e1d0c9 100644 --- a/e2e-testing/src/Installation/InstallationPool.php +++ b/e2e-testing/src/Installation/InstallationPool.php @@ -38,7 +38,7 @@ public function acquire(CacheConfig $cache, string $fingerprint): InstallationLe continue; } - $directory = Path::join($cache->rootDirectory, 'cache/installations', $fingerprint, (string) $slot); + $directory = Path::join($cache->installationsDirectory(), $fingerprint, (string) $slot); $this->filesystem->mkdir($directory); return new InstallationLease($directory, $slot, $lock); diff --git a/e2e-testing/tests/CacheMetadataCommandTest.php b/e2e-testing/tests/CacheMetadataCommandTest.php new file mode 100644 index 0000000..68b23d7 --- /dev/null +++ b/e2e-testing/tests/CacheMetadataCommandTest.php @@ -0,0 +1,123 @@ +directory = \dirname(__DIR__, 2).'/.contao-e2e/runtime/unit-tests/cache-command-'.bin2hex(random_bytes(6)); + $filesystem = new Filesystem(); + $filesystem->mkdir([ + $this->directory.'/package/bin/node_modules/playwright', + $this->directory.'/package/bin/node_modules/playwright-core', + ]); + $filesystem->dumpFile( + $this->directory.'/package/bin/node_modules/playwright/package.json', + '{"name":"playwright","version":"1.63.0"}', + ); + $filesystem->dumpFile( + $this->directory.'/package/bin/node_modules/playwright-core/browsers.json', + '{"browsers":[{"name":"firefox","revision":"1543","installByDefault":true}]}', + ); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->directory); + } + + public function testProducesJsonOutput(): void + { + $tester = new CommandTester(new CacheMetadataCommand($this->metadataFactory())); + + $this->assertSame(0, $tester->execute([])); + $metadata = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(1, $metadata['schema_version']); + $this->assertSame('1.63.0', $metadata['playwright']['version']); + $this->assertArrayHasKey('fingerprint', $metadata['playwright']); + $this->assertArrayHasKey('fingerprint', $metadata['managed_edition']); + $this->assertSame( + [ + 'composer', + 'dependency_locks', + 'installations', + ], + array_keys($metadata['managed_edition']['paths']), + ); + } + + public function testWritesEscapedMultilineGithubActionsOutput(): void + { + $path = tempnam(sys_get_temp_dir(), 'contao-e2e-output-'); + $this->assertIsString($path); + $writer = new GithubOutputWriter(); + + try { + $writer->write($path, [ + 'single_value' => 'example', + 'multiple_paths' => "first path\nsecond path", + ]); + $contents = file_get_contents($path); + $this->assertIsString($contents); + $this->assertMatchesRegularExpression('/single_value<<(CONTAO_E2E_[A-F0-9]+)\nexample\n\\1\n/', $contents); + $this->assertMatchesRegularExpression('/multiple_paths<<(CONTAO_E2E_[A-F0-9]+)\nfirst path\nsecond path\n\\1\n/', $contents); + } finally { + unlink($path); + } + } + + public function testCommandWritesGithubActionsOutputs(): void + { + $path = tempnam(sys_get_temp_dir(), 'contao-e2e-command-output-'); + $this->assertIsString($path); + putenv('GITHUB_OUTPUT='.$path); + $tester = new CommandTester(new CacheMetadataCommand($this->metadataFactory())); + + try { + $this->assertSame(0, $tester->execute(['--github-output' => true])); + $contents = file_get_contents($path); + $this->assertIsString($contents); + $this->assertStringContainsString('playwright_fingerprint<<', $contents); + $this->assertStringContainsString('playwright_path<<', $contents); + $this->assertStringContainsString('managed_edition_fingerprint<<', $contents); + $this->assertStringContainsString('managed_edition_paths<<', $contents); + $this->assertStringContainsString("cache/composer\n", $contents); + $this->assertStringContainsString("cache/dependency-locks\n", $contents); + $this->assertStringContainsString("cache/installations\n", $contents); + } finally { + putenv('GITHUB_OUTPUT'); + unlink($path); + } + } + + private function metadataFactory(): CacheMetadataFactory + { + return new CacheMetadataFactory( + playwrightPackageDirectory: $this->directory.'/package', + browserDirectory: $this->directory.'/browsers', + operatingSystem: 'Linux', + architecture: 'x86_64', + ); + } +} diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php new file mode 100644 index 0000000..ee57e84 --- /dev/null +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -0,0 +1,165 @@ +directory = Path::join(\dirname(__DIR__, 2), '.contao-e2e/runtime/unit-tests/cache-metadata-'.bin2hex(random_bytes(6))); + $filesystem = new Filesystem(); + $filesystem->mkdir([ + $this->directory.'/package/bin/node_modules/playwright', + $this->directory.'/package/bin/node_modules/playwright-core', + ]); + $this->writePlaywrightVersion('1.63.0'); + $filesystem->dumpFile($this->directory.'/package/bin/node_modules/playwright-core/browsers.json', <<<'JSON' + { + "browsers": [ + {"name": "chromium", "revision": "1243", "installByDefault": true}, + {"name": "firefox", "revision": "1543", "installByDefault": true}, + {"name": "webkit", "revision": "2359", "revisionOverrides": {"mac14-arm64": "2251"}, "installByDefault": true}, + {"name": "android", "revision": "1001", "installByDefault": false} + ] + } + JSON); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->directory); + } + + public function testProducesDeterministicSeparateFingerprintsAndStablePaths(): void + { + $factory = $this->factory(); + $config = CacheConfig::forProject($this->directory.'/project'); + $first = $factory->create($config); + $second = $factory->create($config); + + $this->assertSame($first['playwright']['fingerprint'], $second['playwright']['fingerprint']); + $this->assertSame($first['managed_edition']['fingerprint'], $second['managed_edition']['fingerprint']); + $this->assertNotSame($first['playwright']['fingerprint'], $first['managed_edition']['fingerprint']); + $this->assertSame($this->directory.'/browsers', $first['playwright']['path']); + $this->assertSame( + [ + 'composer' => $this->directory.'/project/.contao-e2e/cache/composer', + 'dependency_locks' => $this->directory.'/project/.contao-e2e/cache/dependency-locks', + 'installations' => $this->directory.'/project/.contao-e2e/cache/installations', + ], + $first['managed_edition']['paths'], + ); + + $this->assertArrayNotHasKey('database', $first['managed_edition']['paths']); + $this->assertArrayNotHasKey('locks', $first['managed_edition']['paths']); + $this->assertArrayNotHasKey('runtime', $first['managed_edition']['paths']); + $this->assertArrayNotHasKey('failures', $first['managed_edition']['paths']); + } + + public function testPlaywrightVersionInvalidatesOnlyTheBrowserFingerprint(): void + { + $config = CacheConfig::forProject($this->directory.'/project'); + $initial = $this->factory()->create($config); + $this->writePlaywrightVersion('1.64.0'); + $changed = $this->factory()->create($config); + + $this->assertNotSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); + $this->assertSame($initial['managed_edition']['fingerprint'], $changed['managed_edition']['fingerprint']); + } + + public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void + { + $this->expectException(E2eTestException::class); + $this->expectExceptionMessage('vendor/bin/playwright-install'); + + (new CacheMetadataFactory(playwrightPackageDirectory: $this->directory.'/missing-package')) + ->create(CacheConfig::forProject($this->directory.'/project')) + ; + } + + public function testUsesConfiguredPlaywrightBrowserDirectory(): void + { + putenv('PLAYWRIGHT_BROWSERS_PATH=relative-browser-cache'); + putenv('INIT_CWD='.$this->directory); + + try { + $metadata = (new CacheMetadataFactory(playwrightPackageDirectory: $this->directory.'/package')) + ->create(CacheConfig::forProject($this->directory.'/project')) + ; + $this->assertSame($this->directory.'/relative-browser-cache', $metadata['playwright']['path']); + } finally { + putenv('PLAYWRIGHT_BROWSERS_PATH'); + putenv('INIT_CWD'); + } + } + + /** + * @param array $override + */ + #[DataProvider('managedEditionCompatibilityProvider')] + public function testRelevantCompatibilityChangesInvalidateOnlyTheManagedEditionFingerprint(array $override): void + { + $config = CacheConfig::forProject($this->directory.'/project'); + $initial = $this->factory()->create($config); + $changed = $this->factory($override)->create($config); + + $this->assertSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); + $this->assertNotSame($initial['managed_edition']['fingerprint'], $changed['managed_edition']['fingerprint']); + } + + /** + * @return iterable}> + */ + public static function managedEditionCompatibilityProvider(): iterable + { + yield 'cache format' => [['cache_format' => 2]]; + yield 'PHP version' => [['php' => '9.0']]; + yield 'operating system' => [['operating_system' => 'ExampleOS']]; + yield 'architecture' => [['architecture' => 'example-architecture']]; + yield 'E2E package' => [['packages' => ['contao/e2e-testing' => '2.0.0.0']]]; + yield 'recipe package' => [['packages' => ['contao/installation-recipe' => '2.0.0.0']]]; + yield 'Composer configuration' => [['composer' => ['prefer_lowest' => true]]]; + } + + /** + * @param array $compatibility + */ + private function factory(array $compatibility = []): CacheMetadataFactory + { + return new CacheMetadataFactory( + playwrightPackageDirectory: $this->directory.'/package', + browserDirectory: $this->directory.'/browsers', + compatibilityOverrides: $compatibility, + operatingSystem: 'Linux', + architecture: 'x86_64', + ); + } + + private function writePlaywrightVersion(string $version): void + { + (new Filesystem())->dumpFile( + $this->directory.'/package/bin/node_modules/playwright/package.json', + json_encode(['name' => 'playwright', 'version' => $version], JSON_THROW_ON_ERROR), + ); + } +} diff --git a/e2e-testing/tests/E2eApplicationTest.php b/e2e-testing/tests/E2eApplicationTest.php index c70fc9d..1118df8 100644 --- a/e2e-testing/tests/E2eApplicationTest.php +++ b/e2e-testing/tests/E2eApplicationTest.php @@ -23,6 +23,7 @@ public function testRegistersTheWorkspaceCommands(): void $this->assertTrue($application->has('doctor')); $this->assertTrue($application->has('cache:clear')); + $this->assertTrue($application->has('cache:metadata')); $this->assertTrue($application->has('database:stop')); $this->assertTrue($application->has('failures:clear')); $this->assertTrue($application->find('database:stop')->getDefinition()->hasOption('force')); From 9575a2eee9006b925a6465aed849ea54761199aa Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Wed, 30 Sep 2026 23:56:17 +0200 Subject: [PATCH 2/9] Split feature branches --- monorepo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monorepo.yml b/monorepo.yml index afa3884..3738edb 100644 --- a/monorepo.yml +++ b/monorepo.yml @@ -1,5 +1,5 @@ monorepo_url: https://github.com/contao/setup-and-testing.git -branch_filter: /^(main|\d+\.\d+)$/ +branch_filter: /^(main|\d+\.\d+|feature\/.+)$/ composer: require: [] From bb704b6b3542087b8e4083a2f70dbc429e9f479c Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 11:37:51 +0200 Subject: [PATCH 3/9] Simplify CI cache metadata integration --- README.md | 2 +- e2e-testing/README.md | 34 +++++----- e2e-testing/src/Cache/CacheConfig.php | 21 +++++- .../src/Cache/CacheMetadataFactory.php | 52 +------------- e2e-testing/src/Cache/WorkspaceCleaner.php | 2 +- .../src/Cache/WorkspaceInitializer.php | 2 + .../src/Command/CacheMetadataCommand.php | 42 +++++------- .../src/Command/GithubOutputWriter.php | 50 -------------- .../tests/CacheMetadataCommandTest.php | 68 +++++-------------- .../tests/CacheMetadataFactoryTest.php | 19 +----- .../tests/WorkspaceInitializerTest.php | 2 + 11 files changed, 79 insertions(+), 215 deletions(-) delete mode 100644 e2e-testing/src/Command/GithubOutputWriter.php diff --git a/README.md b/README.md index a59ee34..fecd853 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/e2e-testing/README.md b/e2e-testing/README.md index e924e0e..12372eb 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -109,13 +109,15 @@ Use `vendor/bin/playwright-install --with-deps` on a fresh Linux CI runner to in ## CI caches -`cache:metadata` exposes separate cache fingerprints and paths for Playwright browser binaries and Managed Edition dependencies: +`cache:metadata` writes separate portable keys for Playwright browser binaries and Managed Edition dependencies, then prints their metadata as JSON: ```shell vendor/bin/contao-e2e cache:metadata ``` -The command prints stable JSON containing `playwright` and `managed_edition` objects. The Playwright fingerprint uses the concrete version from the installed Node package and the browser revisions from Playwright's installed browser registry. The Managed Edition fingerprint covers the cache format, 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 keys are written to `.contao-e2e/cache-keys/playwright` and `.contao-e2e/cache-keys/managed-edition`. Any CI system can use their contents directly or hash the files. They remain separate because browser binaries and Managed Edition dependencies have different invalidation rules. + +The Playwright fingerprint uses the concrete version from the installed Node package and the browser revisions from Playwright's installed browser registry. The Managed Edition fingerprint covers the cache format, 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: @@ -124,20 +126,16 @@ 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` only reads installed metadata and never performs this preparation itself. - -Pass `--github-output` to also write cache keys and paths to the file in `GITHUB_OUTPUT`. JSON remains on standard output and has no GitHub Actions-specific encoding. Multiline paths use GitHub Actions' delimiter format: - -```shell -vendor/bin/contao-e2e cache:metadata --github-output -``` +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 restore both caches independently: +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: .contao-e2e/cache/playwright steps: - uses: actions/checkout@v6 @@ -154,20 +152,22 @@ jobs: run: vendor/bin/playwright-install - name: Calculate E2E cache metadata - id: e2e-cache - run: vendor/bin/contao-e2e cache:metadata --github-output + run: vendor/bin/contao-e2e cache:metadata - name: Restore Playwright browsers uses: actions/cache@v4 with: - path: ${{ steps.e2e-cache.outputs.playwright_path }} - key: playwright-${{ steps.e2e-cache.outputs.playwright_fingerprint }} + path: .contao-e2e/cache/playwright + key: playwright-${{ hashFiles('.contao-e2e/cache-keys/playwright') }} - name: Restore Managed Edition caches uses: actions/cache@v4 with: - path: ${{ steps.e2e-cache.outputs.managed_edition_paths }} - key: contao-e2e-${{ steps.e2e-cache.outputs.managed_edition_fingerprint }} + path: | + .contao-e2e/cache/composer + .contao-e2e/cache/dependency-locks + .contao-e2e/cache/installations + key: contao-e2e-${{ hashFiles('.contao-e2e/cache-keys/managed-edition') }} - name: Install and verify Playwright browsers run: vendor/bin/playwright-install --browsers @@ -176,7 +176,7 @@ jobs: run: vendor/bin/phpunit --configuration=phpunit.xml.dist ``` -The Managed Edition paths contain only `.contao-e2e/cache/composer`, `.contao-e2e/cache/dependency-locks`, and `.contao-e2e/cache/installations`. 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 Actions cache key. +The cache root contains only reusable Playwright, Composer, dependency lock, and installation data. 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. diff --git a/e2e-testing/src/Cache/CacheConfig.php b/e2e-testing/src/Cache/CacheConfig.php index 7bd0ace..cb8ae5d 100644 --- a/e2e-testing/src/Cache/CacheConfig.php +++ b/e2e-testing/src/Cache/CacheConfig.php @@ -40,18 +40,33 @@ 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 composerCacheDirectory(): string { - return Path::join($this->rootDirectory, 'cache/composer'); + return Path::join($this->cacheDirectory(), 'composer'); } public function dependencyLocksDirectory(): string { - return Path::join($this->rootDirectory, 'cache/dependency-locks'); + return Path::join($this->cacheDirectory(), 'dependency-locks'); } public function installationsDirectory(): string { - return Path::join($this->rootDirectory, 'cache/installations'); + return Path::join($this->cacheDirectory(), 'installations'); + } + + public function playwrightCacheDirectory(): string + { + return Path::join($this->cacheDirectory(), 'playwright'); + } + + public function cacheKeysDirectory(): string + { + return Path::join($this->rootDirectory, 'cache-keys'); } } diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php index 8dc6fb8..dbd4c68 100644 --- a/e2e-testing/src/Cache/CacheMetadataFactory.php +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -23,7 +23,6 @@ */ public function __construct( private string|null $playwrightPackageDirectory = null, - private string|null $browserDirectory = null, private array $compatibilityOverrides = [], private string|null $operatingSystem = null, private string|null $architecture = null, @@ -44,7 +43,7 @@ public function create(CacheConfig $config): array return [ 'schema_version' => 1, - 'playwright' => $this->playwrightMetadata(), + 'playwright' => $this->playwrightMetadata($config), 'managed_edition' => [ 'fingerprint' => $this->fingerprint->calculate($compatibility), 'paths' => [ @@ -64,7 +63,7 @@ public function create(CacheConfig $config): array * browsers: array}> * } */ - private function playwrightMetadata(): array + private function playwrightMetadata(CacheConfig $config): array { $playwrightDirectory = realpath(Path::join($this->packageDirectory(), 'bin/node_modules/playwright')); @@ -87,7 +86,7 @@ private function playwrightMetadata(): array return [ 'fingerprint' => $this->fingerprint->calculate([$version, $browsers, $platform]), - 'path' => $this->browserDirectory ?? $this->resolveBrowserDirectory($coreDirectory), + 'path' => $config->playwrightCacheDirectory(), 'version' => $version, 'browsers' => $browsers, ]; @@ -189,51 +188,6 @@ private function readJson(string $path): array return json_decode($contents, true, flags: JSON_THROW_ON_ERROR); } - private function resolveBrowserDirectory(string $coreDirectory): string - { - $configured = getenv('PLAYWRIGHT_BROWSERS_PATH'); - - if ('0' === $configured) { - return Path::join($coreDirectory, '.local-browsers'); - } - - if (false !== $configured && '' !== $configured) { - $workingDirectory = getenv('INIT_CWD') ?: (string) getcwd(); - - return Path::makeAbsolute($configured, $workingDirectory); - } - - return Path::join($this->defaultCacheDirectory(), 'ms-playwright'); - } - - private function defaultCacheDirectory(): string - { - if ('Windows' === ($this->operatingSystem ?? PHP_OS_FAMILY)) { - return $this->environmentDirectory('LOCALAPPDATA') - ?? Path::join($this->homeDirectory(), 'AppData/Local'); - } - - if ('Darwin' === ($this->operatingSystem ?? PHP_OS_FAMILY)) { - return Path::join($this->homeDirectory(), 'Library/Caches'); - } - - return $this->environmentDirectory('XDG_CACHE_HOME') ?? Path::join($this->homeDirectory(), '.cache'); - } - - private function homeDirectory(): string - { - return $this->environmentDirectory('HOME') - ?? $this->environmentDirectory('USERPROFILE') - ?? throw new E2eTestException('Could not determine the Playwright browser cache directory.'); - } - - private function environmentDirectory(string $name): string|null - { - $value = getenv($name); - - return false === $value || '' === $value ? null : $value; - } - /** * @return array */ diff --git a/e2e-testing/src/Cache/WorkspaceCleaner.php b/e2e-testing/src/Cache/WorkspaceCleaner.php index d3b3687..04ca098 100644 --- a/e2e-testing/src/Cache/WorkspaceCleaner.php +++ b/e2e-testing/src/Cache/WorkspaceCleaner.php @@ -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); } diff --git a/e2e-testing/src/Cache/WorkspaceInitializer.php b/e2e-testing/src/Cache/WorkspaceInitializer.php index 8ba1b2e..18b969b 100644 --- a/e2e-testing/src/Cache/WorkspaceInitializer.php +++ b/e2e-testing/src/Cache/WorkspaceInitializer.php @@ -29,6 +29,8 @@ public function initialize(CacheConfig $config): void $config->composerCacheDirectory(), $config->dependencyLocksDirectory(), $config->installationsDirectory(), + $config->playwrightCacheDirectory(), + $config->cacheKeysDirectory(), Path::join($config->rootDirectory, 'database'), Path::join($config->rootDirectory, 'failures'), Path::join($config->rootDirectory, 'locks'), diff --git a/e2e-testing/src/Command/CacheMetadataCommand.php b/e2e-testing/src/Command/CacheMetadataCommand.php index 531a81e..260ab48 100644 --- a/e2e-testing/src/Command/CacheMetadataCommand.php +++ b/e2e-testing/src/Command/CacheMetadataCommand.php @@ -13,50 +13,42 @@ namespace Contao\E2eTesting\Command; use Contao\E2eTesting\Cache\CacheMetadataFactory; -use Contao\E2eTesting\Exception\E2eTestException; +use Contao\E2eTesting\Cache\WorkspaceInitializer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; -#[AsCommand('cache:metadata', 'Print cache fingerprints and paths for CI systems')] +#[AsCommand('cache:metadata', 'Write cache keys and print metadata for CI systems')] final class CacheMetadataCommand extends AbstractWorkspaceCommand { public function __construct( private readonly CacheMetadataFactory $metadataFactory = new CacheMetadataFactory(), - private readonly GithubOutputWriter $githubOutputWriter = new GithubOutputWriter(), + private readonly WorkspaceInitializer $workspaceInitializer = new WorkspaceInitializer(), + private readonly Filesystem $filesystem = new Filesystem(), ) { parent::__construct(); } - protected function configure(): void - { - $this->addOption('github-output', null, InputOption::VALUE_NONE, 'Also write values to the file named by GITHUB_OUTPUT'); - } - protected function execute(InputInterface $input, OutputInterface $output): int { - $metadata = $this->metadataFactory->create($this->cache()); + $config = $this->cache(); + $this->workspaceInitializer->initialize($config); + $metadata = $this->metadataFactory->create($config); + $this->filesystem->dumpFile( + Path::join($config->cacheKeysDirectory(), 'playwright'), + $metadata['playwright']['fingerprint']."\n", + ); + $this->filesystem->dumpFile( + Path::join($config->cacheKeysDirectory(), 'managed-edition'), + $metadata['managed_edition']['fingerprint']."\n", + ); $output->writeln(json_encode( $metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, )); - if ($input->getOption('github-output')) { - $path = getenv('GITHUB_OUTPUT'); - - if (false === $path || '' === $path) { - throw new E2eTestException('The GITHUB_OUTPUT environment variable is required with --github-output.'); - } - - $this->githubOutputWriter->write($path, [ - 'playwright_fingerprint' => $metadata['playwright']['fingerprint'], - 'playwright_path' => $metadata['playwright']['path'], - 'managed_edition_fingerprint' => $metadata['managed_edition']['fingerprint'], - 'managed_edition_paths' => implode("\n", array_values($metadata['managed_edition']['paths'])), - ]); - } - return self::SUCCESS; } } diff --git a/e2e-testing/src/Command/GithubOutputWriter.php b/e2e-testing/src/Command/GithubOutputWriter.php deleted file mode 100644 index 542b51c..0000000 --- a/e2e-testing/src/Command/GithubOutputWriter.php +++ /dev/null @@ -1,50 +0,0 @@ - $values - */ - public function write(string $path, array $values): void - { - $output = ''; - - foreach ($values as $name => $value) { - if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name)) { - throw new E2eTestException(\sprintf('The GitHub Actions output name "%s" is invalid.', $name)); - } - - $delimiter = $this->delimiter($name, $value); - $output .= $name.'<<'.$delimiter."\n".$value."\n".$delimiter."\n"; - } - - if (false === file_put_contents($path, $output, FILE_APPEND | LOCK_EX)) { - throw new E2eTestException(\sprintf('Could not write GitHub Actions outputs to "%s".', $path)); - } - } - - private function delimiter(string $name, string $value): string - { - $delimiter = 'CONTAO_E2E_'.strtoupper(substr(hash('sha256', $name."\0".$value), 0, 16)); - - while (\in_array($delimiter, preg_split('/\R/', $value) ?: [], true)) { - $delimiter .= '_X'; - } - - return $delimiter; - } -} diff --git a/e2e-testing/tests/CacheMetadataCommandTest.php b/e2e-testing/tests/CacheMetadataCommandTest.php index 68b23d7..3045375 100644 --- a/e2e-testing/tests/CacheMetadataCommandTest.php +++ b/e2e-testing/tests/CacheMetadataCommandTest.php @@ -14,7 +14,6 @@ use Contao\E2eTesting\Cache\CacheMetadataFactory; use Contao\E2eTesting\Command\CacheMetadataCommand; -use Contao\E2eTesting\Command\GithubOutputWriter; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Filesystem\Filesystem; @@ -46,17 +45,29 @@ protected function tearDown(): void (new Filesystem())->remove($this->directory); } - public function testProducesJsonOutput(): void + public function testWritesPortableCacheKeysAndProducesJsonOutput(): void { + $cacheDirectory = $this->directory.'/workspace'; + putenv('CONTAO_E2E_DIRECTORY='.$cacheDirectory); $tester = new CommandTester(new CacheMetadataCommand($this->metadataFactory())); - $this->assertSame(0, $tester->execute([])); - $metadata = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + try { + $this->assertSame(0, $tester->execute([])); + $metadata = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + } finally { + putenv('CONTAO_E2E_DIRECTORY'); + } $this->assertSame(1, $metadata['schema_version']); $this->assertSame('1.63.0', $metadata['playwright']['version']); - $this->assertArrayHasKey('fingerprint', $metadata['playwright']); - $this->assertArrayHasKey('fingerprint', $metadata['managed_edition']); + $this->assertSame( + $metadata['playwright']['fingerprint']."\n", + file_get_contents($cacheDirectory.'/cache-keys/playwright'), + ); + $this->assertSame( + $metadata['managed_edition']['fingerprint']."\n", + file_get_contents($cacheDirectory.'/cache-keys/managed-edition'), + ); $this->assertSame( [ 'composer', @@ -67,55 +78,10 @@ public function testProducesJsonOutput(): void ); } - public function testWritesEscapedMultilineGithubActionsOutput(): void - { - $path = tempnam(sys_get_temp_dir(), 'contao-e2e-output-'); - $this->assertIsString($path); - $writer = new GithubOutputWriter(); - - try { - $writer->write($path, [ - 'single_value' => 'example', - 'multiple_paths' => "first path\nsecond path", - ]); - $contents = file_get_contents($path); - $this->assertIsString($contents); - $this->assertMatchesRegularExpression('/single_value<<(CONTAO_E2E_[A-F0-9]+)\nexample\n\\1\n/', $contents); - $this->assertMatchesRegularExpression('/multiple_paths<<(CONTAO_E2E_[A-F0-9]+)\nfirst path\nsecond path\n\\1\n/', $contents); - } finally { - unlink($path); - } - } - - public function testCommandWritesGithubActionsOutputs(): void - { - $path = tempnam(sys_get_temp_dir(), 'contao-e2e-command-output-'); - $this->assertIsString($path); - putenv('GITHUB_OUTPUT='.$path); - $tester = new CommandTester(new CacheMetadataCommand($this->metadataFactory())); - - try { - $this->assertSame(0, $tester->execute(['--github-output' => true])); - $contents = file_get_contents($path); - $this->assertIsString($contents); - $this->assertStringContainsString('playwright_fingerprint<<', $contents); - $this->assertStringContainsString('playwright_path<<', $contents); - $this->assertStringContainsString('managed_edition_fingerprint<<', $contents); - $this->assertStringContainsString('managed_edition_paths<<', $contents); - $this->assertStringContainsString("cache/composer\n", $contents); - $this->assertStringContainsString("cache/dependency-locks\n", $contents); - $this->assertStringContainsString("cache/installations\n", $contents); - } finally { - putenv('GITHUB_OUTPUT'); - unlink($path); - } - } - private function metadataFactory(): CacheMetadataFactory { return new CacheMetadataFactory( playwrightPackageDirectory: $this->directory.'/package', - browserDirectory: $this->directory.'/browsers', operatingSystem: 'Linux', architecture: 'x86_64', ); diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php index ee57e84..5d3f4c2 100644 --- a/e2e-testing/tests/CacheMetadataFactoryTest.php +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -60,7 +60,7 @@ public function testProducesDeterministicSeparateFingerprintsAndStablePaths(): v $this->assertSame($first['playwright']['fingerprint'], $second['playwright']['fingerprint']); $this->assertSame($first['managed_edition']['fingerprint'], $second['managed_edition']['fingerprint']); $this->assertNotSame($first['playwright']['fingerprint'], $first['managed_edition']['fingerprint']); - $this->assertSame($this->directory.'/browsers', $first['playwright']['path']); + $this->assertSame($this->directory.'/project/.contao-e2e/cache/playwright', $first['playwright']['path']); $this->assertSame( [ 'composer' => $this->directory.'/project/.contao-e2e/cache/composer', @@ -97,22 +97,6 @@ public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void ; } - public function testUsesConfiguredPlaywrightBrowserDirectory(): void - { - putenv('PLAYWRIGHT_BROWSERS_PATH=relative-browser-cache'); - putenv('INIT_CWD='.$this->directory); - - try { - $metadata = (new CacheMetadataFactory(playwrightPackageDirectory: $this->directory.'/package')) - ->create(CacheConfig::forProject($this->directory.'/project')) - ; - $this->assertSame($this->directory.'/relative-browser-cache', $metadata['playwright']['path']); - } finally { - putenv('PLAYWRIGHT_BROWSERS_PATH'); - putenv('INIT_CWD'); - } - } - /** * @param array $override */ @@ -148,7 +132,6 @@ private function factory(array $compatibility = []): CacheMetadataFactory { return new CacheMetadataFactory( playwrightPackageDirectory: $this->directory.'/package', - browserDirectory: $this->directory.'/browsers', compatibilityOverrides: $compatibility, operatingSystem: 'Linux', architecture: 'x86_64', diff --git a/e2e-testing/tests/WorkspaceInitializerTest.php b/e2e-testing/tests/WorkspaceInitializerTest.php index e201974..5bfea5b 100644 --- a/e2e-testing/tests/WorkspaceInitializerTest.php +++ b/e2e-testing/tests/WorkspaceInitializerTest.php @@ -30,6 +30,8 @@ public function testCreatesTheProjectLocalWorkspace(): void $this->assertFileExists($project.'/.contao-e2e/.managed-by-contao-e2e'); $this->assertSame("*\n!.gitignore\n", file_get_contents($project.'/.contao-e2e/.gitignore')); $this->assertDirectoryExists($project.'/.contao-e2e/cache/installations'); + $this->assertDirectoryExists($project.'/.contao-e2e/cache/playwright'); + $this->assertDirectoryExists($project.'/.contao-e2e/cache-keys'); $this->assertDirectoryExists($project.'/.contao-e2e/database'); } } From 456dd693945c7943b4f884b86ea95f9bf6c18114 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 11:55:58 +0200 Subject: [PATCH 4/9] Group managed edition caches --- e2e-testing/README.md | 9 +++------ e2e-testing/src/Cache/CacheConfig.php | 11 ++++++++--- e2e-testing/src/Cache/CacheMetadataFactory.php | 3 ++- e2e-testing/tests/CacheMetadataFactoryTest.php | 10 +++++++--- e2e-testing/tests/WorkspaceInitializerTest.php | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/e2e-testing/README.md b/e2e-testing/README.md index 12372eb..3a6b017 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -163,10 +163,7 @@ jobs: - name: Restore Managed Edition caches uses: actions/cache@v4 with: - path: | - .contao-e2e/cache/composer - .contao-e2e/cache/dependency-locks - .contao-e2e/cache/installations + path: .contao-e2e/cache/managed-edition key: contao-e2e-${{ hashFiles('.contao-e2e/cache-keys/managed-edition') }} - name: Install and verify Playwright browsers @@ -176,7 +173,7 @@ jobs: run: vendor/bin/phpunit --configuration=phpunit.xml.dist ``` -The cache root contains only reusable Playwright, Composer, dependency lock, and installation data. 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. +The cache root contains separate `playwright` and `managed-edition` groups. The package owns the contents of each group, so adding another reusable Managed Edition 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. @@ -448,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///project`. The matching +Full Managed Editions are stored below `.contao-e2e/cache/managed-edition/installations///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//data`. The `runtime/` directory only contains the lightweight webserver router and origin mapping. diff --git a/e2e-testing/src/Cache/CacheConfig.php b/e2e-testing/src/Cache/CacheConfig.php index cb8ae5d..87c9a6a 100644 --- a/e2e-testing/src/Cache/CacheConfig.php +++ b/e2e-testing/src/Cache/CacheConfig.php @@ -45,19 +45,24 @@ public function cacheDirectory(): string return Path::join($this->rootDirectory, 'cache'); } + public function managedEditionCacheDirectory(): string + { + return Path::join($this->cacheDirectory(), 'managed-edition'); + } + public function composerCacheDirectory(): string { - return Path::join($this->cacheDirectory(), 'composer'); + return Path::join($this->managedEditionCacheDirectory(), 'composer'); } public function dependencyLocksDirectory(): string { - return Path::join($this->cacheDirectory(), 'dependency-locks'); + return Path::join($this->managedEditionCacheDirectory(), 'dependency-locks'); } public function installationsDirectory(): string { - return Path::join($this->cacheDirectory(), 'installations'); + return Path::join($this->managedEditionCacheDirectory(), 'installations'); } public function playwrightCacheDirectory(): string diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php index dbd4c68..bcb8fec 100644 --- a/e2e-testing/src/Cache/CacheMetadataFactory.php +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -34,7 +34,7 @@ public function __construct( * @return array{ * schema_version: int, * playwright: array{fingerprint: string, path: string, version: string, browsers: array}>}, - * managed_edition: array{fingerprint: string, paths: array{composer: string, dependency_locks: string, installations: string}} + * managed_edition: array{fingerprint: string, path: string, paths: array{composer: string, dependency_locks: string, installations: string}} * } */ public function create(CacheConfig $config): array @@ -46,6 +46,7 @@ public function create(CacheConfig $config): array 'playwright' => $this->playwrightMetadata($config), 'managed_edition' => [ 'fingerprint' => $this->fingerprint->calculate($compatibility), + 'path' => $config->managedEditionCacheDirectory(), 'paths' => [ 'composer' => $config->composerCacheDirectory(), 'dependency_locks' => $config->dependencyLocksDirectory(), diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php index 5d3f4c2..ceaac51 100644 --- a/e2e-testing/tests/CacheMetadataFactoryTest.php +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -63,12 +63,16 @@ public function testProducesDeterministicSeparateFingerprintsAndStablePaths(): v $this->assertSame($this->directory.'/project/.contao-e2e/cache/playwright', $first['playwright']['path']); $this->assertSame( [ - 'composer' => $this->directory.'/project/.contao-e2e/cache/composer', - 'dependency_locks' => $this->directory.'/project/.contao-e2e/cache/dependency-locks', - 'installations' => $this->directory.'/project/.contao-e2e/cache/installations', + 'composer' => $this->directory.'/project/.contao-e2e/cache/managed-edition/composer', + 'dependency_locks' => $this->directory.'/project/.contao-e2e/cache/managed-edition/dependency-locks', + 'installations' => $this->directory.'/project/.contao-e2e/cache/managed-edition/installations', ], $first['managed_edition']['paths'], ); + $this->assertSame( + $this->directory.'/project/.contao-e2e/cache/managed-edition', + $first['managed_edition']['path'], + ); $this->assertArrayNotHasKey('database', $first['managed_edition']['paths']); $this->assertArrayNotHasKey('locks', $first['managed_edition']['paths']); diff --git a/e2e-testing/tests/WorkspaceInitializerTest.php b/e2e-testing/tests/WorkspaceInitializerTest.php index 5bfea5b..fd99c99 100644 --- a/e2e-testing/tests/WorkspaceInitializerTest.php +++ b/e2e-testing/tests/WorkspaceInitializerTest.php @@ -29,7 +29,7 @@ public function testCreatesTheProjectLocalWorkspace(): void $this->assertFileExists($project.'/.contao-e2e/.managed-by-contao-e2e'); $this->assertSame("*\n!.gitignore\n", file_get_contents($project.'/.contao-e2e/.gitignore')); - $this->assertDirectoryExists($project.'/.contao-e2e/cache/installations'); + $this->assertDirectoryExists($project.'/.contao-e2e/cache/managed-edition/installations'); $this->assertDirectoryExists($project.'/.contao-e2e/cache/playwright'); $this->assertDirectoryExists($project.'/.contao-e2e/cache-keys'); $this->assertDirectoryExists($project.'/.contao-e2e/database'); From e0c046bbea7c61e552893b47090d670c8d66eb9d Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 12:37:28 +0200 Subject: [PATCH 5/9] Generalize reusable E2E cache --- e2e-testing/README.md | 14 ++++---- e2e-testing/src/Cache/CacheConfig.php | 10 +++--- .../src/Cache/CacheMetadataFactory.php | 35 ++++++------------- e2e-testing/src/Command/CacheClearCommand.php | 2 +- .../src/Command/CacheMetadataCommand.php | 4 +-- .../tests/CacheMetadataCommandTest.php | 17 +++++---- .../tests/CacheMetadataFactoryTest.php | 27 +++++--------- .../tests/WorkspaceInitializerTest.php | 2 +- 8 files changed, 42 insertions(+), 69 deletions(-) diff --git a/e2e-testing/README.md b/e2e-testing/README.md index 3a6b017..c091827 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -109,13 +109,13 @@ Use `vendor/bin/playwright-install --with-deps` on a fresh Linux CI runner to in ## CI caches -`cache:metadata` writes separate portable keys for Playwright browser binaries and Managed Edition dependencies, then prints their metadata as JSON: +`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/managed-edition`. Any CI system can use their contents directly or hash the files. They remain separate because browser binaries and Managed Edition dependencies have different invalidation rules. +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 and the browser revisions from Playwright's installed browser registry. The Managed Edition fingerprint covers the cache format, 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. @@ -160,11 +160,11 @@ jobs: path: .contao-e2e/cache/playwright key: playwright-${{ hashFiles('.contao-e2e/cache-keys/playwright') }} - - name: Restore Managed Edition caches + - name: Restore E2E setup cache uses: actions/cache@v4 with: - path: .contao-e2e/cache/managed-edition - key: contao-e2e-${{ hashFiles('.contao-e2e/cache-keys/managed-edition') }} + 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 @@ -173,7 +173,7 @@ jobs: run: vendor/bin/phpunit --configuration=phpunit.xml.dist ``` -The cache root contains separate `playwright` and `managed-edition` groups. The package owns the contents of each group, so adding another reusable Managed Edition 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. +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. @@ -445,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/managed-edition/installations///project`. The matching +Full Managed Editions are stored below `.contao-e2e/cache/e2e/installations///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//data`. The `runtime/` directory only contains the lightweight webserver router and origin mapping. diff --git a/e2e-testing/src/Cache/CacheConfig.php b/e2e-testing/src/Cache/CacheConfig.php index 87c9a6a..0f61d16 100644 --- a/e2e-testing/src/Cache/CacheConfig.php +++ b/e2e-testing/src/Cache/CacheConfig.php @@ -45,24 +45,24 @@ public function cacheDirectory(): string return Path::join($this->rootDirectory, 'cache'); } - public function managedEditionCacheDirectory(): string + public function e2eCacheDirectory(): string { - return Path::join($this->cacheDirectory(), 'managed-edition'); + return Path::join($this->cacheDirectory(), 'e2e'); } public function composerCacheDirectory(): string { - return Path::join($this->managedEditionCacheDirectory(), 'composer'); + return Path::join($this->e2eCacheDirectory(), 'composer'); } public function dependencyLocksDirectory(): string { - return Path::join($this->managedEditionCacheDirectory(), 'dependency-locks'); + return Path::join($this->e2eCacheDirectory(), 'dependency-locks'); } public function installationsDirectory(): string { - return Path::join($this->managedEditionCacheDirectory(), 'installations'); + return Path::join($this->e2eCacheDirectory(), 'installations'); } public function playwrightCacheDirectory(): string diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php index bcb8fec..4ad0601 100644 --- a/e2e-testing/src/Cache/CacheMetadataFactory.php +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -33,8 +33,8 @@ public function __construct( /** * @return array{ * schema_version: int, - * playwright: array{fingerprint: string, path: string, version: string, browsers: array}>}, - * managed_edition: array{fingerprint: string, path: string, paths: array{composer: string, dependency_locks: string, installations: string}} + * playwright: array{fingerprint: string, path: string}, + * e2e: array{fingerprint: string, path: string} * } */ public function create(CacheConfig $config): array @@ -43,28 +43,18 @@ public function create(CacheConfig $config): array return [ 'schema_version' => 1, - 'playwright' => $this->playwrightMetadata($config), - 'managed_edition' => [ + 'playwright' => [ + 'fingerprint' => $this->playwrightFingerprint(), + 'path' => $config->playwrightCacheDirectory(), + ], + 'e2e' => [ 'fingerprint' => $this->fingerprint->calculate($compatibility), - 'path' => $config->managedEditionCacheDirectory(), - 'paths' => [ - 'composer' => $config->composerCacheDirectory(), - 'dependency_locks' => $config->dependencyLocksDirectory(), - 'installations' => $config->installationsDirectory(), - ], + 'path' => $config->e2eCacheDirectory(), ], ]; } - /** - * @return array{ - * fingerprint: string, - * path: string, - * version: string, - * browsers: array}> - * } - */ - private function playwrightMetadata(CacheConfig $config): array + private function playwrightFingerprint(): string { $playwrightDirectory = realpath(Path::join($this->packageDirectory(), 'bin/node_modules/playwright')); @@ -85,12 +75,7 @@ private function playwrightMetadata(CacheConfig $config): array 'architecture' => $this->architecture ?? php_uname('m'), ]; - return [ - 'fingerprint' => $this->fingerprint->calculate([$version, $browsers, $platform]), - 'path' => $config->playwrightCacheDirectory(), - 'version' => $version, - 'browsers' => $browsers, - ]; + return $this->fingerprint->calculate([$version, $browsers, $platform]); } private function packageDirectory(): string diff --git a/e2e-testing/src/Command/CacheClearCommand.php b/e2e-testing/src/Command/CacheClearCommand.php index 58f1012..a6a40ed 100644 --- a/e2e-testing/src/Command/CacheClearCommand.php +++ b/e2e-testing/src/Command/CacheClearCommand.php @@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand('cache:clear', 'Clear reusable dependency and Managed Edition caches')] +#[AsCommand('cache:clear', 'Clear reusable E2E setup caches')] final class CacheClearCommand extends AbstractWorkspaceCommand { protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/e2e-testing/src/Command/CacheMetadataCommand.php b/e2e-testing/src/Command/CacheMetadataCommand.php index 260ab48..ea66ad4 100644 --- a/e2e-testing/src/Command/CacheMetadataCommand.php +++ b/e2e-testing/src/Command/CacheMetadataCommand.php @@ -41,8 +41,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $metadata['playwright']['fingerprint']."\n", ); $this->filesystem->dumpFile( - Path::join($config->cacheKeysDirectory(), 'managed-edition'), - $metadata['managed_edition']['fingerprint']."\n", + Path::join($config->cacheKeysDirectory(), 'e2e'), + $metadata['e2e']['fingerprint']."\n", ); $output->writeln(json_encode( $metadata, diff --git a/e2e-testing/tests/CacheMetadataCommandTest.php b/e2e-testing/tests/CacheMetadataCommandTest.php index 3045375..bc9a851 100644 --- a/e2e-testing/tests/CacheMetadataCommandTest.php +++ b/e2e-testing/tests/CacheMetadataCommandTest.php @@ -59,22 +59,21 @@ public function testWritesPortableCacheKeysAndProducesJsonOutput(): void } $this->assertSame(1, $metadata['schema_version']); - $this->assertSame('1.63.0', $metadata['playwright']['version']); $this->assertSame( $metadata['playwright']['fingerprint']."\n", file_get_contents($cacheDirectory.'/cache-keys/playwright'), ); $this->assertSame( - $metadata['managed_edition']['fingerprint']."\n", - file_get_contents($cacheDirectory.'/cache-keys/managed-edition'), + $metadata['e2e']['fingerprint']."\n", + file_get_contents($cacheDirectory.'/cache-keys/e2e'), ); $this->assertSame( - [ - 'composer', - 'dependency_locks', - 'installations', - ], - array_keys($metadata['managed_edition']['paths']), + $cacheDirectory.'/cache/playwright', + $metadata['playwright']['path'], + ); + $this->assertSame( + $cacheDirectory.'/cache/e2e', + $metadata['e2e']['path'], ); } diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php index ceaac51..190756b 100644 --- a/e2e-testing/tests/CacheMetadataFactoryTest.php +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -58,26 +58,15 @@ public function testProducesDeterministicSeparateFingerprintsAndStablePaths(): v $second = $factory->create($config); $this->assertSame($first['playwright']['fingerprint'], $second['playwright']['fingerprint']); - $this->assertSame($first['managed_edition']['fingerprint'], $second['managed_edition']['fingerprint']); - $this->assertNotSame($first['playwright']['fingerprint'], $first['managed_edition']['fingerprint']); + $this->assertSame($first['e2e']['fingerprint'], $second['e2e']['fingerprint']); + $this->assertNotSame($first['playwright']['fingerprint'], $first['e2e']['fingerprint']); $this->assertSame($this->directory.'/project/.contao-e2e/cache/playwright', $first['playwright']['path']); $this->assertSame( - [ - 'composer' => $this->directory.'/project/.contao-e2e/cache/managed-edition/composer', - 'dependency_locks' => $this->directory.'/project/.contao-e2e/cache/managed-edition/dependency-locks', - 'installations' => $this->directory.'/project/.contao-e2e/cache/managed-edition/installations', - ], - $first['managed_edition']['paths'], + $this->directory.'/project/.contao-e2e/cache/e2e', + $first['e2e']['path'], ); - $this->assertSame( - $this->directory.'/project/.contao-e2e/cache/managed-edition', - $first['managed_edition']['path'], - ); - - $this->assertArrayNotHasKey('database', $first['managed_edition']['paths']); - $this->assertArrayNotHasKey('locks', $first['managed_edition']['paths']); - $this->assertArrayNotHasKey('runtime', $first['managed_edition']['paths']); - $this->assertArrayNotHasKey('failures', $first['managed_edition']['paths']); + $this->assertSame(['fingerprint', 'path'], array_keys($first['playwright'])); + $this->assertSame(['fingerprint', 'path'], array_keys($first['e2e'])); } public function testPlaywrightVersionInvalidatesOnlyTheBrowserFingerprint(): void @@ -88,7 +77,7 @@ public function testPlaywrightVersionInvalidatesOnlyTheBrowserFingerprint(): voi $changed = $this->factory()->create($config); $this->assertNotSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); - $this->assertSame($initial['managed_edition']['fingerprint'], $changed['managed_edition']['fingerprint']); + $this->assertSame($initial['e2e']['fingerprint'], $changed['e2e']['fingerprint']); } public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void @@ -112,7 +101,7 @@ public function testRelevantCompatibilityChangesInvalidateOnlyTheManagedEditionF $changed = $this->factory($override)->create($config); $this->assertSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); - $this->assertNotSame($initial['managed_edition']['fingerprint'], $changed['managed_edition']['fingerprint']); + $this->assertNotSame($initial['e2e']['fingerprint'], $changed['e2e']['fingerprint']); } /** diff --git a/e2e-testing/tests/WorkspaceInitializerTest.php b/e2e-testing/tests/WorkspaceInitializerTest.php index fd99c99..a66d0f9 100644 --- a/e2e-testing/tests/WorkspaceInitializerTest.php +++ b/e2e-testing/tests/WorkspaceInitializerTest.php @@ -29,7 +29,7 @@ public function testCreatesTheProjectLocalWorkspace(): void $this->assertFileExists($project.'/.contao-e2e/.managed-by-contao-e2e'); $this->assertSame("*\n!.gitignore\n", file_get_contents($project.'/.contao-e2e/.gitignore')); - $this->assertDirectoryExists($project.'/.contao-e2e/cache/managed-edition/installations'); + $this->assertDirectoryExists($project.'/.contao-e2e/cache/e2e/installations'); $this->assertDirectoryExists($project.'/.contao-e2e/cache/playwright'); $this->assertDirectoryExists($project.'/.contao-e2e/cache-keys'); $this->assertDirectoryExists($project.'/.contao-e2e/database'); From 0e714beb2060216d93d50f8516f3cd0258b3c8c3 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 14:42:40 +0200 Subject: [PATCH 6/9] Cleanup --- e2e-testing/README.md | 2 +- e2e-testing/src/Cache/CacheConfig.php | 2 -- .../src/Cache/CacheMetadataFactory.php | 19 +++++++------- .../src/Cache/FingerprintCalculator.php | 17 +++++++----- e2e-testing/src/Cache/ValueFingerprint.php | 21 --------------- e2e-testing/src/Command/CacheClearCommand.php | 2 +- .../tests/CacheMetadataFactoryTest.php | 26 ++++++++++++------- 7 files changed, 38 insertions(+), 51 deletions(-) delete mode 100644 e2e-testing/src/Cache/ValueFingerprint.php diff --git a/e2e-testing/README.md b/e2e-testing/README.md index c091827..9371056 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -117,7 +117,7 @@ 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 and the browser revisions from Playwright's installed browser registry. The Managed Edition fingerprint covers the cache format, 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 fingerprint uses the concrete version from the installed Node package and the browser revisions from Playwright's installed browser registry. 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: diff --git a/e2e-testing/src/Cache/CacheConfig.php b/e2e-testing/src/Cache/CacheConfig.php index 0f61d16..8f1301c 100644 --- a/e2e-testing/src/Cache/CacheConfig.php +++ b/e2e-testing/src/Cache/CacheConfig.php @@ -16,8 +16,6 @@ final readonly class CacheConfig { - public const FORMAT_VERSION = 1; - private function __construct( public string $projectDirectory, public string $rootDirectory, diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php index 4ad0601..cb7b37a 100644 --- a/e2e-testing/src/Cache/CacheMetadataFactory.php +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -26,7 +26,6 @@ public function __construct( private array $compatibilityOverrides = [], private string|null $operatingSystem = null, private string|null $architecture = null, - private ValueFingerprint $fingerprint = new ValueFingerprint(), ) { } @@ -48,7 +47,7 @@ public function create(CacheConfig $config): array 'path' => $config->playwrightCacheDirectory(), ], 'e2e' => [ - 'fingerprint' => $this->fingerprint->calculate($compatibility), + 'fingerprint' => $this->hash($compatibility), 'path' => $config->e2eCacheDirectory(), ], ]; @@ -75,7 +74,7 @@ private function playwrightFingerprint(): string 'architecture' => $this->architecture ?? php_uname('m'), ]; - return $this->fingerprint->calculate([$version, $browsers, $platform]); + return $this->hash([$version, $browsers, $platform]); } private function packageDirectory(): string @@ -180,7 +179,6 @@ private function readJson(string $path): array private function compatibility(): array { return [ - 'cache_format' => CacheConfig::FORMAT_VERSION, 'php' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'operating_system' => $this->operatingSystem ?? PHP_OS_FAMILY, 'architecture' => $this->architecture ?? php_uname('m'), @@ -206,15 +204,11 @@ private function packageVersion(string $package): string } /** - * @return array + * @return array */ private function composerConfiguration(): array { - $configuration = [ - 'executable' => 'composer', - 'prefer_lowest' => false, - 'prefer_stable' => true, - ]; + $configuration = []; foreach ([ 'COMPOSER_IGNORE_PLATFORM_REQ', @@ -232,4 +226,9 @@ private function composerConfiguration(): array return $configuration; } + + private function hash(mixed $value): string + { + return hash('sha256', serialize($value)); + } } diff --git a/e2e-testing/src/Cache/FingerprintCalculator.php b/e2e-testing/src/Cache/FingerprintCalculator.php index bbf0a08..1fc94d9 100644 --- a/e2e-testing/src/Cache/FingerprintCalculator.php +++ b/e2e-testing/src/Cache/FingerprintCalculator.php @@ -18,10 +18,8 @@ final readonly class FingerprintCalculator { - public function __construct( - private SourceFingerprintInterface $sourceFingerprint = new ProcessCachedSourceFingerprint(), - private ValueFingerprint $valueFingerprint = new ValueFingerprint(), - ) { + public function __construct(private SourceFingerprintInterface $sourceFingerprint = new ProcessCachedSourceFingerprint()) + { } public function calculate(ManagedEditionConfig $config): FingerprintSet @@ -29,7 +27,7 @@ public function calculate(ManagedEditionConfig $config): FingerprintSet $recipe = $config->recipe; $projectDirectory = $config->environment->cache->projectDirectory; $composer = $config->environment->composer; - $dependency = $this->valueFingerprint->calculate([ + $dependency = $this->hash([ $recipe->composer->toArray($projectDirectory), $this->hashFiles(array_map( static fn ($package) => Path::join($package->path, 'composer.json'), @@ -47,18 +45,23 @@ public function calculate(ManagedEditionConfig $config): FingerprintSet $sources[$package->package] = $this->sourceFingerprint->calculate($package->path); } - $application = $this->valueFingerprint->calculate([ + $application = $this->hash([ $dependency, $config->appEnvironment, $sources, $this->hashFiles(array_map(static fn ($fragment) => $fragment->path, $recipe->assets->configFragments)), $this->hashMappings($recipe->assets->fileMappings), ]); - $data = $this->valueFingerprint->calculate([$application, $this->hashFiles($recipe->fixtures->files)]); + $data = $this->hash([$application, $this->hashFiles($recipe->fixtures->files)]); return new FingerprintSet($dependency, $application, $data); } + private function hash(mixed $value): string + { + return hash('sha256', serialize($value)); + } + /** * @param list $files * diff --git a/e2e-testing/src/Cache/ValueFingerprint.php b/e2e-testing/src/Cache/ValueFingerprint.php deleted file mode 100644 index a5d2432..0000000 --- a/e2e-testing/src/Cache/ValueFingerprint.php +++ /dev/null @@ -1,21 +0,0 @@ -clearCache($this->cache()); - (new SymfonyStyle($input, $output))->success('E2E dependency and installation caches cleared.'); + (new SymfonyStyle($input, $output))->success('Reusable E2E setup caches cleared.'); return self::SUCCESS; } diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php index 190756b..00ddc37 100644 --- a/e2e-testing/tests/CacheMetadataFactoryTest.php +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -69,15 +69,24 @@ public function testProducesDeterministicSeparateFingerprintsAndStablePaths(): v $this->assertSame(['fingerprint', 'path'], array_keys($first['e2e'])); } - public function testPlaywrightVersionInvalidatesOnlyTheBrowserFingerprint(): void + public function testPlaywrightMetadataInvalidatesOnlyTheBrowserFingerprint(): void { $config = CacheConfig::forProject($this->directory.'/project'); $initial = $this->factory()->create($config); $this->writePlaywrightVersion('1.64.0'); - $changed = $this->factory()->create($config); + $versionChanged = $this->factory()->create($config); - $this->assertNotSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); - $this->assertSame($initial['e2e']['fingerprint'], $changed['e2e']['fingerprint']); + $this->assertNotSame($initial['playwright']['fingerprint'], $versionChanged['playwright']['fingerprint']); + $this->assertSame($initial['e2e']['fingerprint'], $versionChanged['e2e']['fingerprint']); + + (new Filesystem())->dumpFile( + $this->directory.'/package/bin/node_modules/playwright-core/browsers.json', + '{"browsers":[{"name":"firefox","revision":"1544","installByDefault":true}]}', + ); + $revisionChanged = $this->factory()->create($config); + + $this->assertNotSame($versionChanged['playwright']['fingerprint'], $revisionChanged['playwright']['fingerprint']); + $this->assertSame($versionChanged['e2e']['fingerprint'], $revisionChanged['e2e']['fingerprint']); } public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void @@ -93,8 +102,8 @@ public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void /** * @param array $override */ - #[DataProvider('managedEditionCompatibilityProvider')] - public function testRelevantCompatibilityChangesInvalidateOnlyTheManagedEditionFingerprint(array $override): void + #[DataProvider('e2eCompatibilityProvider')] + public function testRelevantCompatibilityChangesInvalidateOnlyTheE2eFingerprint(array $override): void { $config = CacheConfig::forProject($this->directory.'/project'); $initial = $this->factory()->create($config); @@ -107,15 +116,14 @@ public function testRelevantCompatibilityChangesInvalidateOnlyTheManagedEditionF /** * @return iterable}> */ - public static function managedEditionCompatibilityProvider(): iterable + public static function e2eCompatibilityProvider(): iterable { - yield 'cache format' => [['cache_format' => 2]]; yield 'PHP version' => [['php' => '9.0']]; yield 'operating system' => [['operating_system' => 'ExampleOS']]; yield 'architecture' => [['architecture' => 'example-architecture']]; yield 'E2E package' => [['packages' => ['contao/e2e-testing' => '2.0.0.0']]]; yield 'recipe package' => [['packages' => ['contao/installation-recipe' => '2.0.0.0']]]; - yield 'Composer configuration' => [['composer' => ['prefer_lowest' => true]]]; + yield 'Composer configuration' => [['composer' => ['COMPOSER_PREFER_LOWEST' => '1']]]; } /** From d925d1d72dc8b13f3915dd645a3871c42cabebf4 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 15:32:59 +0200 Subject: [PATCH 7/9] Cleanup --- e2e-testing/src/Command/CacheMetadataCommand.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/e2e-testing/src/Command/CacheMetadataCommand.php b/e2e-testing/src/Command/CacheMetadataCommand.php index ea66ad4..c50e2c4 100644 --- a/e2e-testing/src/Command/CacheMetadataCommand.php +++ b/e2e-testing/src/Command/CacheMetadataCommand.php @@ -23,24 +23,22 @@ #[AsCommand('cache:metadata', 'Write cache keys and print metadata for CI systems')] final class CacheMetadataCommand extends AbstractWorkspaceCommand { - public function __construct( - private readonly CacheMetadataFactory $metadataFactory = new CacheMetadataFactory(), - private readonly WorkspaceInitializer $workspaceInitializer = new WorkspaceInitializer(), - private readonly Filesystem $filesystem = new Filesystem(), - ) { + public function __construct(private readonly CacheMetadataFactory|null $metadataFactory = null) + { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { $config = $this->cache(); - $this->workspaceInitializer->initialize($config); - $metadata = $this->metadataFactory->create($config); - $this->filesystem->dumpFile( + (new WorkspaceInitializer())->initialize($config); + $metadata = ($this->metadataFactory ?? new CacheMetadataFactory())->create($config); + $filesystem = new Filesystem(); + $filesystem->dumpFile( Path::join($config->cacheKeysDirectory(), 'playwright'), $metadata['playwright']['fingerprint']."\n", ); - $this->filesystem->dumpFile( + $filesystem->dumpFile( Path::join($config->cacheKeysDirectory(), 'e2e'), $metadata['e2e']['fingerprint']."\n", ); From 6aec0e97c8558d5f86a87208d48a80e919b8a196 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 15:38:25 +0200 Subject: [PATCH 8/9] Fix browser cache paths and platform compatibility --- e2e-testing/README.md | 4 ++-- e2e-testing/src/Cache/CacheMetadataFactory.php | 15 +++++++++++++++ e2e-testing/tests/CacheMetadataFactoryTest.php | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/e2e-testing/README.md b/e2e-testing/README.md index 9371056..686bdab 100644 --- a/e2e-testing/README.md +++ b/e2e-testing/README.md @@ -117,7 +117,7 @@ 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 and the browser revisions from Playwright's installed browser registry. 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 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: @@ -135,7 +135,7 @@ jobs: e2e: runs-on: ubuntu-latest env: - PLAYWRIGHT_BROWSERS_PATH: .contao-e2e/cache/playwright + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.contao-e2e/cache/playwright steps: - uses: actions/checkout@v6 diff --git a/e2e-testing/src/Cache/CacheMetadataFactory.php b/e2e-testing/src/Cache/CacheMetadataFactory.php index cb7b37a..8ec5bf5 100644 --- a/e2e-testing/src/Cache/CacheMetadataFactory.php +++ b/e2e-testing/src/Cache/CacheMetadataFactory.php @@ -26,6 +26,7 @@ public function __construct( private array $compatibilityOverrides = [], private string|null $operatingSystem = null, private string|null $architecture = null, + private string|null $operatingSystemVersion = null, ) { } @@ -71,12 +72,26 @@ private function playwrightFingerprint(): string $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) { diff --git a/e2e-testing/tests/CacheMetadataFactoryTest.php b/e2e-testing/tests/CacheMetadataFactoryTest.php index 00ddc37..4905392 100644 --- a/e2e-testing/tests/CacheMetadataFactoryTest.php +++ b/e2e-testing/tests/CacheMetadataFactoryTest.php @@ -99,6 +99,19 @@ public function testExplainsHowToPrepareMissingPlaywrightDependencies(): void ; } + public function testOperatingSystemVersionInvalidatesOnlyTheBrowserFingerprint(): void + { + $config = CacheConfig::forProject($this->directory.'/project'); + $initial = $this->factory(operatingSystemVersion: 'ubuntu-22.04')->create($config); + $changed = $this->factory(operatingSystemVersion: 'ubuntu-24.04')->create($config); + $differentDistribution = $this->factory(operatingSystemVersion: 'debian-12')->create($config); + + $this->assertNotSame($initial['playwright']['fingerprint'], $changed['playwright']['fingerprint']); + $this->assertNotSame($changed['playwright']['fingerprint'], $differentDistribution['playwright']['fingerprint']); + $this->assertSame($initial['e2e']['fingerprint'], $changed['e2e']['fingerprint']); + $this->assertSame($changed['e2e']['fingerprint'], $differentDistribution['e2e']['fingerprint']); + } + /** * @param array $override */ @@ -129,13 +142,14 @@ public static function e2eCompatibilityProvider(): iterable /** * @param array $compatibility */ - private function factory(array $compatibility = []): CacheMetadataFactory + private function factory(array $compatibility = [], string $operatingSystemVersion = 'ubuntu-24.04'): CacheMetadataFactory { return new CacheMetadataFactory( playwrightPackageDirectory: $this->directory.'/package', compatibilityOverrides: $compatibility, operatingSystem: 'Linux', architecture: 'x86_64', + operatingSystemVersion: $operatingSystemVersion, ); } From 0a58f9e3586da61f0857586851574a519b8ee121 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2026 16:03:18 +0200 Subject: [PATCH 9/9] Windows again --- e2e-testing/tests/CacheMetadataCommandTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e-testing/tests/CacheMetadataCommandTest.php b/e2e-testing/tests/CacheMetadataCommandTest.php index bc9a851..ac00813 100644 --- a/e2e-testing/tests/CacheMetadataCommandTest.php +++ b/e2e-testing/tests/CacheMetadataCommandTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; final class CacheMetadataCommandTest extends TestCase { @@ -24,7 +25,7 @@ final class CacheMetadataCommandTest extends TestCase protected function setUp(): void { - $this->directory = \dirname(__DIR__, 2).'/.contao-e2e/runtime/unit-tests/cache-command-'.bin2hex(random_bytes(6)); + $this->directory = Path::join(\dirname(__DIR__, 2), '.contao-e2e/runtime/unit-tests/cache-command-'.bin2hex(random_bytes(6))); $filesystem = new Filesystem(); $filesystem->mkdir([ $this->directory.'/package/bin/node_modules/playwright',