From 655796b4b955d19f29e3ce5d8fa33864e494d53a Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Sun, 2 Aug 2026 12:38:53 +0200 Subject: [PATCH 1/2] [FEATURE] Provision functional test instances in composer mode TYPO3 ships two installation modes and only one of them was covered by functional tests: every test instance was a classic mode installation, even though composer mode is what most projects run. Set TYPO3_TESTING_INSTANCE_MODE=composer to provision composer mode instances instead. Classic mode stays the default, and the mode is part of the instance identifier, so a test case's classic and composer instances never collide. A composer mode instance is not its own composer installation. One shared installation containing every system extension and every fixture extension is built once per run by Build/Scripts/setupFunctionalComposerSuperset.php, and each instance borrows its vendor tree and its package artifact by symlink: /vendor -> /vendor /public/_assets -> /public/_assets /config/system own configuration /public own document root, fileadmin, typo3temp/assets /var own caches and logs Per test case only the *active* package set differs, which is expressed by narrowing the artifact in memory. Building a real installation per test configuration was measured at about five seconds each, which for the roughly two hundred configurations in this repository would cost far more than the provisioning this saves. Classes are deliberately not loaded from the shared installation. The root autoloader already maps every system extension and every fixture extension, so no second autoloader is registered and no second copy of any third party package is ever loaded. One consequence: class alias maps that the shared installation registered at install time are not active, because the root installation never processed those packages. Notable details, each of which was a bug on the way and none of which fails loudly: * TYPO3 decides between the classic layout (typo3conf, typo3temp/var) and the composer layout (config, var) by whether the project root and the document root differ - not by composer mode. Test instances therefore set TYPO3_PATH_ROOT to the public directory in composer mode. * Package::getPackagePath() resolves the artifact's relative path against Environment::getProjectPath() once and writes the result back. Caching one PackageCacheEntry for the process therefore binds every package to whichever instance touched it first, and every later instance looks for its packages inside that instance. Only the raw artifact data is cached; the entry is rebuilt whenever the instance changes. * On the command line in composer mode TYPO3 reports the console binary as the entry script, because in a real installation it sits outside the document root. Functional tests are command line processes simulating web requests, and NormalizedParams derives the site URL by subtracting the entry script's directory from the request - so it subtracted typo3/sysext/core/bin/ from every request, the site path collapsed to an empty string, and every generated link silently lost its leading slash. The front end entry script is reported instead, which is what a web request has. * A console command run in a sub process cannot use EXT:core/bin/typo3: that script derives its autoloader from its own directory, and since the shared installation's packages are symlinks into the checkout, it resolves back to the root autoloader, never defines TYPO3_COMPOSER_MODE and runs in classic mode. Composer mode instances get a generated entry point instead, which getCliEntryPoint() hands to the test. * Applications bootstrapped by a test - the install tool, notably - must boot through bootTestInstance(), because Bootstrap resolves the artifact from the root installation, and the mono repo has none. * fileadmin and typo3temp/assets are web accessible and belong below public/. Getting that wrong makes the default file storage point at nothing. * Frontend sub requests bootstrap a second time and need the same narrowed package set. Their server parameters also have to name the real document root, or the site path is derived from a script outside the public path. * The sites configuration cache removal in tearDown() has to use the layout's variable path, or it silently does nothing and the stale configuration leaks into the next test. * Paths declared by test cases are given relative to the document root, which only carries the extension tree in classic mode; they now fall back to the original root. A "PKG:typo3/app:" resource identifier carries a path relative to the project root, which is the document root only in classic mode, so getAppRelativePublicPath() is provided for tests naming such resources. TestingBootstrapRunner overrides Bootstrap::createPackageCache() through late static binding, so no change to typo3/cms is required. --- .../ComposerMode/ComposerModeInstance.php | 317 ++++++++++++++++++ .../ComposerMode/InMemoryPackageCache.php | 56 ++++ .../Core/Functional/FunctionalTestCase.php | 246 ++++++++++++-- Classes/Core/SystemEnvironmentBuilder.php | 27 +- Classes/Core/Testbase.php | 47 ++- Classes/Core/TestingBootstrapPackageCache.php | 33 ++ Classes/Core/TestingBootstrapRunner.php | 42 +++ 7 files changed, 724 insertions(+), 44 deletions(-) create mode 100644 Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php create mode 100644 Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php create mode 100644 Classes/Core/TestingBootstrapPackageCache.php create mode 100644 Classes/Core/TestingBootstrapRunner.php diff --git a/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php new file mode 100644 index 00000000..5c6e1fed --- /dev/null +++ b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php @@ -0,0 +1,317 @@ +/vendor -> /vendor + * /public/_assets -> /public/_assets + * /config/system/… own configuration + * /var, fileadmin, … own state + * + * Package paths inside the artifact are relative to the project path, so the vendor + * symlink is what makes them resolve. Classes are *not* loaded from the superset: + * the root autoloader already maps every system extension and every fixture extension, + * so registering a second autoloader - and with it a second copy of every third party + * package - is unnecessary and avoided. + * + * Per test case, only the active package set differs. That is expressed by narrowing the + * artifact's configuration, which the package manager treats as "these are active" while + * still knowing about every installed package. + * + * @internal + */ +final class ComposerModeInstance +{ + /** + * Raw artifact data, read from disk once per process. + * + * Deliberately the raw array and not the PackageCacheEntry built from it: see + * getSupersetArtifact(). + */ + private static ?array $supersetArtifactData = null; + + /** + * The artifact currently handed out, and the instance it was built for. + */ + private static ?PackageCacheEntry $supersetArtifact = null; + private static string $supersetArtifactInstancePath = ''; + + /** + * Absolute path of the shared superset installation. + */ + public static function getSupersetPath(): string + { + $configured = (string)getenv('TYPO3_TESTING_SUPERSET_PATH'); + if ($configured !== '') { + return rtrim($configured, '/'); + } + if (!defined('ORIGINAL_ROOT')) { + throw new Exception('ORIGINAL_ROOT is not defined, cannot locate the composer superset.', 1754092800); + } + return rtrim(ORIGINAL_ROOT, '/') . '/typo3temp/var/tests/composer-superset'; + } + + /** + * Fails with an actionable message rather than a confusing bootstrap error when the + * superset has not been built. + */ + public static function assertSupersetIsBuilt(): void + { + $artifact = self::getSupersetPath() . '/vendor/typo3/PackageArtifact.php'; + if (!is_file($artifact)) { + throw new Exception( + sprintf( + 'Composer mode functional tests need the shared composer installation, which was' + . ' not found at "%s". Build it with' + . ' Build/Scripts/setupFunctionalComposerSuperset.php, or run the suite through' + . ' runTests.sh, which builds it for you.', + self::getSupersetPath() + ), + 1754092801 + ); + } + } + + /** + * Creates the instance directory. Everything shared with other instances is a symlink, + * everything a test may write to is the instance's own. + * + * @param non-empty-string $instancePath + * @param non-empty-string[] $additionalFoldersToCreate + */ + public static function provisionInstanceDirectory(string $instancePath, array $additionalFoldersToCreate = []): void + { + $superset = self::getSupersetPath(); + // Plain mkdir rather than GeneralUtility::mkdir_deep: this runs before the + // instance is bootstrapped, so the global configuration the latter reads its + // permission masks from does not exist yet. + // A composer installation splits the instance in two: everything web accessible + // lives below public/, everything else beside it. fileadmin and typo3temp/assets + // are web accessible, var/ and config/ are not - getting that wrong makes the + // default file storage point at a directory that does not exist. + foreach ([ + '/config/system', + '/var/transient', + '/var/log', + '/public', + '/public/fileadmin', + '/public/typo3temp/assets', + '/public/typo3temp/var/transient', + ] as $directory) { + self::createDirectory($instancePath . $directory); + } + foreach ($additionalFoldersToCreate as $directory) { + self::createDirectory($instancePath . '/public/' . ltrim($directory, '/')); + } + self::symlink($superset . '/vendor', $instancePath . '/vendor'); + if (is_dir($superset . '/public/_assets')) { + self::symlink($superset . '/public/_assets', $instancePath . '/public/_assets'); + } + } + + /** + * Name of the command line entry point written into a composer mode instance. + */ + public const CLI_ENTRY_POINT = 'typo3-testing-cli.php'; + + /** + * Writes the command line entry point a composer mode instance is driven by. + * + * Tests that run a console command in a sub process cannot use EXT:core/bin/typo3 in + * composer mode. That script derives its autoloader from its own __DIR__, and since + * the instance borrows the superset's vendor tree - whose packages are themselves + * symlinks into the core checkout - __DIR__ resolves all the way back to the core + * checkout and the sub process boots with the root autoloader. TYPO3_COMPOSER_MODE is + * then undefined, so the sub process runs in classic mode and scans for packages in a + * directory the instance does not have. + * + * The generated script does in a sub process exactly what setUp() does in the test + * process: force composer mode and boot with this instance's narrowed package set. + * + * @param non-empty-string $instancePath + * @param non-empty-string[] $activeExtensionKeys + */ + public static function writeCliEntryPoint(string $instancePath, array $activeExtensionKeys): void + { + $script = sprintf( + <<<'PHP' + get(\TYPO3\CMS\Core\Console\CommandApplication::class) + ->run() + ); + PHP, + var_export(self::getRootAutoloadPath(), true), + var_export($instancePath, true), + var_export($instancePath . '/public', true), + var_export(self::getSupersetPath(), true), + var_export($instancePath, true), + var_export($instancePath, true), + var_export($activeExtensionKeys, true) + ); + file_put_contents($instancePath . '/' . self::CLI_ENTRY_POINT, $script . PHP_EOL); + } + + /** + * The autoloader the test process itself uses - the one that maps every system + * extension and every fixture extension. + */ + private static function getRootAutoloadPath(): string + { + return (new ComposerPackageManager())->getVendorPath() . '/autoload.php'; + } + + /** + * The package cache a test instance boots from: every package of the superset stays + * known, only the given ones are active. + * + * @param non-empty-string $instancePath + * @param non-empty-string[] $activeExtensionKeys + */ + public static function createPackageCache(string $instancePath, array $activeExtensionKeys): PackageCacheInterface + { + $full = self::getSupersetArtifact($instancePath); + $configuration = $full->getConfiguration(); + $narrowed = ['version' => $configuration['version'] ?? 5, 'packages' => []]; + $missing = []; + foreach ($activeExtensionKeys as $key) { + if (!isset($configuration['packages'][$key])) { + $missing[] = $key; + continue; + } + $narrowed['packages'][$key] = $configuration['packages'][$key]; + } + if ($missing !== []) { + throw new Exception( + sprintf( + 'Extension(s) "%s" are not part of the composer superset installation. Every system' + . ' extension and every fixture extension below Tests/ is installed into it, so this' + . ' usually means the superset is stale - rebuild it with' + . ' Build/Scripts/setupFunctionalComposerSuperset.php.', + implode('", "', $missing) + ), + 1754092802 + ); + } + // The root package of the superset is registered as a package too, and TYPO3 + // expects the app package to be active. + foreach (array_keys($configuration['packages']) as $key) { + if (str_contains((string)$key, '/')) { + $narrowed['packages'][$key] = $configuration['packages'][$key]; + } + } + + return new InMemoryPackageCache( + PackageCacheEntry::fromPackageData( + $narrowed, + $full->getAliasMap(), + $full->getComposerNameMap(), + $full->getPackages() + ) + ); + } + + /** + * The artifact stores package paths relative to the project path, and Package + * resolves them against Environment::getProjectPath() on first access - once, + * writing the absolute path back into the object. Package objects are therefore + * bound to the instance that first touched them, and reusing one artifact across + * instances makes every later instance look for its packages inside the first + * instance's directory. Rebuild the entry whenever the instance changes; only the + * raw artifact data is kept, and reading it from disk happens once per process. + * + * @param non-empty-string $instancePath + */ + private static function getSupersetArtifact(string $instancePath): PackageCacheEntry + { + if (self::$supersetArtifact !== null && self::$supersetArtifactInstancePath === $instancePath) { + return self::$supersetArtifact; + } + if (self::$supersetArtifactData === null) { + self::assertSupersetIsBuilt(); + $data = require self::getSupersetPath() . '/vendor/typo3/PackageArtifact.php'; + if (!is_array($data)) { + throw new Exception('The composer superset package artifact could not be read.', 1754092803); + } + self::$supersetArtifactData = $data; + } + self::$supersetArtifactInstancePath = $instancePath; + + return self::$supersetArtifact = PackageCacheEntry::fromCache(self::$supersetArtifactData); + } + + private static function createDirectory(string $path): void + { + if (is_dir($path)) { + return; + } + if (!@mkdir($path, 0o777, true) && !is_dir($path)) { + throw new Exception(sprintf('Directory "%s" could not be created.', $path), 1754092805); + } + } + + private static function symlink(string $from, string $to): void + { + if (is_link($to) || file_exists($to)) { + return; + } + if (!@symlink($from, $to)) { + throw new Exception(sprintf('Creating link failed: from %s to %s', $from, $to), 1754092804); + } + } +} diff --git a/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php b/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php new file mode 100644 index 00000000..056df2bf --- /dev/null +++ b/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php @@ -0,0 +1,56 @@ +entry; + } + + public function store(PackageCacheEntry $cacheEntry): void + { + // Nothing to store: the entry is derived from the superset artifact on every boot. + } + + public function invalidate(): void + { + // Nothing to invalidate for the same reason. + } + + public function getIdentifier(): string + { + return $this->entry->getIdentifier() ?? 'testing-framework-composer-mode'; + } +} diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index 1dbec1f4..4992e801 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -35,8 +35,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\HttpUtility; use TYPO3\CMS\Frontend\Http\Application; +use TYPO3\TestingFramework\Composer\ComposerPackageManager; use TYPO3\TestingFramework\Core\BaseTestCase; use TYPO3\TestingFramework\Core\Exception; +use TYPO3\TestingFramework\Core\Functional\Framework\ComposerMode\ComposerModeInstance; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\DataSet; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Snapshot\DatabaseAccessor; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Snapshot\DatabaseSnapshot; @@ -44,6 +46,8 @@ use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext; use TYPO3\TestingFramework\Core\Testbase; +use TYPO3\TestingFramework\Core\TestingBootstrapPackageCache; +use TYPO3\TestingFramework\Core\TestingBootstrapRunner; /** * Base test case class for functional tests, all TYPO3 CMS @@ -77,6 +81,13 @@ */ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInterface { + /** + * Where the instance configuration is written. A classic mode installation keeps it + * below the document root, a composer mode installation beside it. + */ + private const SETTINGS_DIR_CLASSIC = 'typo3conf/system'; + private const SETTINGS_DIR_COMPOSER = 'config/system'; + /** * Unique identifier for this test case. Location of the test * instance and database name depend on this. Calculated early in setUp() @@ -264,15 +275,16 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter private ContainerInterface $container; /** - * These two internal variable track if the given test is the first test of - * that test case. This variable is set to current calling test case class. - * Consecutive tests then optimize and do not create a full - * database structure again but instead just truncate all tables which - * is much quicker. + * True when this test had to provision the instance, i.e. when it is the + * first test in this process using this instance configuration. */ - private static string $currentTestCaseClass = ''; private bool $isFirstTest = true; + /** + * Test case class currently being executed, used to detect the first test of a class. + */ + private static string $currentTestCaseClass = ''; + /** * Set up creates a test instance and database. * @@ -286,8 +298,13 @@ protected function setUp(): void $this->identifier = static::getInstanceIdentifier(); $this->instancePath = static::getInstancePath(); - putenv('TYPO3_PATH_ROOT=' . $this->instancePath); + // TYPO3 derives the layout of an installation from whether the project root and + // the public path differ: same directory means the classic layout with typo3conf + // and typo3temp/var, different directories mean the composer layout with config + // and var next to a public/ document root. putenv('TYPO3_PATH_APP=' . $this->instancePath); + putenv('TYPO3_PATH_ROOT=' . $this->instancePath + . ($this->getInstanceMode() === 'composer' ? '/public' : '')); $testbase = new Testbase(); $testbase->setTypo3TestingContext(); @@ -308,11 +325,20 @@ protected function setUp(): void // Reusing an existing instance. This typically happens for the second, third, ... test // in a test case, so environment is set up only once per test case. GeneralUtility::purgeInstances(); - $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); + if ($this->getInstanceMode() === 'composer') { + TestingBootstrapPackageCache::$packageCache = ComposerModeInstance::createPackageCache( + $this->instancePath, + $this->getActiveExtensionKeys() + ); + } + $this->container = $testbase->setUpBasicTypo3Bootstrap( + $this->instancePath, + $this->getInstanceMode() === 'composer' + ); $this->initializeTestDatabaseAndTruncateTables($testbase, $this->initializeDatabase, $dbPathSqlite, $dbPathSqliteEmpty); $testbase->loadExtensionTables(); } else { - DatabaseSnapshot::initialize(dirname($this->getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); + DatabaseSnapshot::initialize(dirname(static::getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); $testbase->removeOldInstanceIfExists($this->instancePath); // Basic instance directory structure $testbase->createDirectory($this->instancePath . '/fileadmin'); @@ -334,11 +360,22 @@ protected function setUp(): void 'Resources/Core/Functional/Extensions/json_response', 'Resources/Core/Functional/Extensions/private_container', ]; - $testbase->setUpInstanceCoreLinks($this->instancePath, $defaultCoreExtensionsToLoad, $this->coreExtensionsToLoad); - $testbase->linkTestExtensionsToInstance($this->instancePath, $this->testExtensionsToLoad); - $testbase->linkFrameworkExtensionsToInstance($this->instancePath, $frameworkExtension); - $testbase->linkPathsInTestInstance($this->instancePath, $this->pathsToLinkInTestInstance); - $testbase->providePathsInTestInstance($this->instancePath, $this->pathsToProvideInTestInstance); + if ($this->getInstanceMode() === 'composer') { + // Composer mode instances borrow the shared installation's vendor tree + // instead of linking extensions into a classic directory structure. + ComposerModeInstance::provisionInstanceDirectory($this->instancePath, $this->additionalFoldersToCreate); + ComposerModeInstance::writeCliEntryPoint($this->instancePath, $this->getActiveExtensionKeys()); + } else { + $testbase->setUpInstanceCoreLinks($this->instancePath, $defaultCoreExtensionsToLoad, $this->coreExtensionsToLoad); + $testbase->linkTestExtensionsToInstance($this->instancePath, $this->testExtensionsToLoad); + $testbase->linkFrameworkExtensionsToInstance($this->instancePath, $frameworkExtension); + } + // Destinations of linked and provided paths are given relative to the document + // root. In classic mode that is the instance directory itself, in composer mode + // it is the public/ directory inside it. + $documentRoot = $this->instancePath . ($this->getInstanceMode() === 'composer' ? '/public' : ''); + $testbase->linkPathsInTestInstance($documentRoot, $this->pathsToLinkInTestInstance); + $testbase->providePathsInTestInstance($documentRoot, $this->pathsToProvideInTestInstance); $localConfiguration = []; $localConfiguration['DB'] = $testbase->getOriginalDatabaseSettingsFromEnvironmentOrLocalConfiguration(); @@ -418,15 +455,32 @@ protected function setUp(): void $localConfiguration['SYS']['caching']['cacheConfigurations']['imagesizes']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $localConfiguration['SYS']['caching']['cacheConfigurations']['pages']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $localConfiguration['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; - $testbase->setUpLocalConfiguration($this->instancePath, $localConfiguration, $this->configurationToUseInTestInstance); - $testbase->setUpPackageStates( + $testbase->setUpLocalConfiguration( $this->instancePath, - $defaultCoreExtensionsToLoad, - $this->coreExtensionsToLoad, - $this->testExtensionsToLoad, - $frameworkExtension + $localConfiguration, + $this->configurationToUseInTestInstance, + $this->getInstanceMode() === 'composer' ? self::SETTINGS_DIR_COMPOSER : self::SETTINGS_DIR_CLASSIC + ); + if ($this->getInstanceMode() === 'composer') { + // No PackageStates in composer mode: the package set comes from the + // artifact of the shared installation, narrowed to this test case. + TestingBootstrapPackageCache::$packageCache = ComposerModeInstance::createPackageCache( + $this->instancePath, + $this->getActiveExtensionKeys() + ); + } else { + $testbase->setUpPackageStates( + $this->instancePath, + $defaultCoreExtensionsToLoad, + $this->coreExtensionsToLoad, + $this->testExtensionsToLoad, + $frameworkExtension + ); + } + $this->container = $testbase->setUpBasicTypo3Bootstrap( + $this->instancePath, + $this->getInstanceMode() === 'composer' ); - $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); $this->initializeTestDatabase( $this->container, $testbase, @@ -450,14 +504,18 @@ protected function tearDown(): void // Remove any site configuration, and it's cache files, most likely created by SiteBasedTestTrait if (!in_array('typo3conf/sites', $this->pathsToLinkInTestInstance, true) && !in_array('typo3conf/sites/', $this->pathsToLinkInTestInstance, true) - && is_dir($this->instancePath . '/typo3conf/sites') + && is_dir($this->getSiteConfigurationPath()) ) { - GeneralUtility::rmdir($this->instancePath . '/typo3conf/sites', true); + GeneralUtility::rmdir($this->getSiteConfigurationPath(), true); } - if (file_exists($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php') - && is_file($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php') - ) { - @unlink($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php'); + // The variable path differs by installation layout. Using the classic one in a + // composer mode instance leaves the cache in place, and the next test case sees + // the site configurations of the previous one - or, worse, its emptiness. + $sitesConfigurationCache = $this->instancePath + . ($this->getInstanceMode() === 'composer' ? '/var' : '/typo3temp/var') + . '/cache/code/core/sites-configuration.php'; + if (is_file($sitesConfigurationCache)) { + @unlink($sitesConfigurationCache); } parent::tearDown(); } @@ -582,7 +640,11 @@ protected function getBackendUserRecordFromDatabase(int $userId): ?array private function createServerRequest(string $url, string $method = 'GET'): ServerRequestInterface { $requestUrlParts = parse_url($url); - $docRoot = $this->instancePath; + // The document root is the instance directory only in classic mode. Getting this + // wrong does not fail outright: the site path is then derived from a script that + // is not below the public path, comes out empty, and every generated link silently + // loses its leading slash. + $docRoot = $this->instancePath . ($this->getInstanceMode() === 'composer' ? '/public' : ''); $serverParams = [ 'DOCUMENT_ROOT' => $docRoot, @@ -1006,7 +1068,12 @@ private function retrieveFrontendSubRequestResult( $_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'] = $request->getUri()->getHost() ?: 'localhost'; $outputBufferingLevel = ob_get_level(); - $container = Bootstrap::init(ClassLoadingInformation::getClassLoader()); + // Frontend sub requests bootstrap a second time. In composer mode that bootstrap + // must use the same narrowed package set as the test instance, otherwise it looks + // for a package artifact next to the root installation, where there is none. + $container = $this->getInstanceMode() === 'composer' + ? TestingBootstrapRunner::init(ClassLoadingInformation::getClassLoader()) + : Bootstrap::init(ClassLoadingInformation::getClassLoader()); /** @var InternalRequest $serverRequest */ $serverRequest = $request->withAttribute('typo3.testing.context', $context); @@ -1103,14 +1170,126 @@ protected function withDatabaseSnapshot(?callable $createCallback = null, ?calla } /** - * Create a 7 char long hash of class name as identifier. + * Installation mode the test instance is provisioned in. + * + * TYPO3 ships two installation modes and both need functional test coverage, so the + * mode is chosen per run rather than baked in. Classic mode stays the default; set + * TYPO3_TESTING_INSTANCE_MODE=composer to provision composer mode instances. + * + * @return 'classic'|'composer' + */ + final protected static function getInstanceMode(): string + { + $mode = (string)getenv('TYPO3_TESTING_INSTANCE_MODE'); + if ($mode === '' || $mode === 'classic') { + return 'classic'; + } + if ($mode === 'composer') { + return 'composer'; + } + throw new \RuntimeException( + sprintf('TYPO3_TESTING_INSTANCE_MODE must be "classic" or "composer", "%s" given.', $mode), + 1754092805 + ); + } + + /** + * Extension keys that make up this test case's instance, as the package manager knows + * them. + * + * Test cases may name extensions as an extension key, a composer package name or a + * classic mode path, and all three have to end up as the key the artifact is indexed + * by. The default set and the framework's own extensions are always active, matching + * what classic mode writes into PackageStates. + * + * @return non-empty-string[] + */ + private function getActiveExtensionKeys(): array + { + $composerPackageManager = new ComposerPackageManager(); + $keys = ['core', 'backend', 'frontend', 'extbase', 'fluid', 'json_response', 'private_container']; + foreach ([...$this->coreExtensionsToLoad, ...$this->testExtensionsToLoad] as $extension) { + $packageInfo = $composerPackageManager->getPackageInfoWithFallback($extension); + $keys[] = $packageInfo?->getExtensionKey() ?? basename($extension); + } + + return array_values(array_unique($keys)); + } + + /** + * Boots this test instance again, the way an entry script would. + * + * Tests that exercise an application (the install tool, most notably) bootstrap a + * second container themselves. In composer mode that bootstrap must use this + * instance's narrowed package set: Bootstrap resolves the package artifact from the + * vendor directory of the *root* installation, and the mono repo has none, so calling + * it directly fails with "Package artifact not found". + */ + final protected function bootTestInstance(bool $failsafe = false): ContainerInterface + { + return $this->getInstanceMode() === 'composer' + ? TestingBootstrapRunner::init(ClassLoadingInformation::getClassLoader(), $failsafe) + : Bootstrap::init(ClassLoadingInformation::getClassLoader(), $failsafe); + } + + /** + * Path of the document root relative to the project root, with a trailing slash, or + * an empty string when the two are the same directory. * - * @internal + * A "PKG:typo3/app:…" resource identifier carries a path relative to the *project + * root*, which is the document root only in classic mode - in composer mode the same + * file lives one level deeper, below the web directory. Tests naming such resources + * have to prefix them with this rather than assuming either layout. + */ + final protected function getAppRelativePublicPath(): string + { + if (Environment::getPublicPath() === Environment::getProjectPath()) { + return ''; + } + + return substr(Environment::getPublicPath(), strlen(Environment::getProjectPath()) + 1) . '/'; + } + + /** + * Absolute path of the PHP script a test uses to run a console command in a sub + * process against this test instance. + * + * Classic mode instances use the core binary directly. Composer mode instances + * cannot: see ComposerModeInstance::writeCliEntryPoint(). + * + * @return non-empty-string + */ + final protected function getCliEntryPoint(): string + { + if ($this->getInstanceMode() === 'composer') { + return $this->instancePath . '/' . ComposerModeInstance::CLI_ENTRY_POINT; + } + + return $this->instancePath . '/typo3/sysext/core/bin/typo3'; + } + + /** + * Where site configurations live in this instance. Classic mode keeps them below + * typo3conf, composer mode below config. + */ + private function getSiteConfigurationPath(): string + { + return $this->instancePath . '/' + . ($this->getInstanceMode() === 'composer' ? 'config' : 'typo3conf') + . '/sites'; + } + + /** + * Identifier of the test instance. The instance directory and the database name + * are derived from it. Composer mode and classic mode instances of the same test + * case must not collide, so the mode is part of it. + * + * @internal Extensions functional tests should usually not fiddle with this. This may break anytime. * @return non-empty-string */ protected static function getInstanceIdentifier(): string { - return substr(sha1(static::class), 0, 7); + return substr(sha1(static::class . '|' . static::getInstanceMode()), 0, 7); } /** @@ -1121,7 +1300,6 @@ protected static function getInstanceIdentifier(): string */ protected static function getInstancePath(): string { - $identifier = static::getInstanceIdentifier(); - return ORIGINAL_ROOT . 'typo3temp/var/tests/functional-' . $identifier; + return ORIGINAL_ROOT . 'typo3temp/var/tests/functional-' . static::getInstanceIdentifier(); } } diff --git a/Classes/Core/SystemEnvironmentBuilder.php b/Classes/Core/SystemEnvironmentBuilder.php index 65199fda..e699fa69 100644 --- a/Classes/Core/SystemEnvironmentBuilder.php +++ b/Classes/Core/SystemEnvironmentBuilder.php @@ -54,11 +54,36 @@ public static function run(int $entryPointLevel = 0, int $requestType = 0, ?bool Environment::getPublicPath(), Environment::getVarPath(), Environment::getConfigPath(), - Environment::getCurrentScript(), + static::determineCurrentScript(), Environment::isWindows() ? 'WINDOWS' : 'UNIX' ); } + /** + * Entry script the instance should be treated as running. + * + * On CLI in composer mode, TYPO3 deliberately reports the console binary + * (typo3/sysext/core/bin/typo3) as the current script, because in a real composer + * installation that binary sits outside the document root and relative path + * calculations would otherwise break. + * + * Functional tests are CLI processes that simulate *web* requests, and the entry + * script is what the site URL is derived from: NormalizedParams subtracts the entry + * script's directory, relative to the public path, from the request URL. Leaving the + * console binary in place therefore subtracts "typo3/sysext/core/bin/" from every + * request and the site path collapses to an empty string - every generated link + * silently loses its leading slash. Point at the front end entry script instead, + * which is what a web request would have and what classic mode already gets. + */ + protected static function determineCurrentScript(): string + { + if (!Environment::isCli() || !static::usesComposerClassLoading()) { + return Environment::getCurrentScript(); + } + + return Environment::getPublicPath() . '/index.php'; + } + /** * Manage composer mode separated from TYPO3_COMPOSER_MODE define set by typo3/cms-composer-installers. * diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 9eb783f9..72e1336a 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -401,9 +401,15 @@ public function linkPathsInTestInstance(string $instancePath, array $pathsToLink { foreach ($pathsToLinkInTestInstance as $sourcePathToLinkInTestInstance => $destinationPathToLinkInTestInstance) { $sourcePath = $instancePath . '/' . ltrim($sourcePathToLinkInTestInstance, '/'); + if (!file_exists($sourcePath)) { + // See providePathsInTestInstance(): sources are given relative to the + // document root, which only carries the extension tree in classic mode. + $sourcePath = rtrim(ORIGINAL_ROOT, '/') . '/' . ltrim($sourcePathToLinkInTestInstance, '/'); + } if (!file_exists($sourcePath)) { throw new Exception( - 'Path ' . $sourcePath . ' not found', + 'Path ' . $sourcePathToLinkInTestInstance . ' not found, neither in the test instance' + . ' nor below ' . ORIGINAL_ROOT, 1476109221 ); } @@ -434,9 +440,16 @@ public function providePathsInTestInstance(string $instancePath, array $pathsToP { foreach ($pathsToProvideInTestInstance as $sourceIdentifier => $designationIdentifier) { $sourcePath = $instancePath . '/' . ltrim($sourceIdentifier, '/'); + if (!file_exists($sourcePath)) { + // Sources are given relative to the instance, which works in classic mode + // because the extensions are linked into it. A composer mode instance has + // no such tree, so fall back to the same path below the original root. + $sourcePath = rtrim(ORIGINAL_ROOT, '/') . '/' . ltrim($sourceIdentifier, '/'); + } if (!file_exists($sourcePath)) { throw new Exception( - 'Path ' . $sourcePath . ' not found', + 'Path ' . $sourceIdentifier . ' not found, neither in the test instance nor below ' + . ORIGINAL_ROOT, 1511956084 ); } @@ -559,16 +572,25 @@ public function testDatabaseNameIsNotTooLong(string $originalDatabaseName, array * @param array $overruleConfiguration Overrule factory and base configuration * @throws Exception */ - public function setUpLocalConfiguration(string $instancePath, array $configuration, array $overruleConfiguration): void - { + /** + * @param non-empty-string $relativeConfigurationPath Where TYPO3 expects its settings file, + * relative to the instance root. Classic mode instances keep it below typo3conf, + * composer mode instances below config. + */ + public function setUpLocalConfiguration( + string $instancePath, + array $configuration, + array $overruleConfiguration, + string $relativeConfigurationPath = 'typo3conf/system' + ): void { // Base of final LocalConfiguration is core factory configuration $coreExtensionPath = $this->composerPackageManager->getPackageInfo('typo3/cms-core')?->getRealPath() ?? ''; $finalConfigurationArray = require $coreExtensionPath . '/Configuration/FactoryConfiguration.php'; $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $configuration); $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $overruleConfiguration); - $this->createDirectory($instancePath . '/typo3conf/system'); + $this->createDirectory($instancePath . '/' . trim($relativeConfigurationPath, '/')); $result = @file_put_contents( - $instancePath . '/typo3conf/system/settings.php', + $instancePath . '/' . trim($relativeConfigurationPath, '/') . '/settings.php', 'getPackagesPath() . '/autoload.php'; - SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, false); + SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, $composerMode); $outputBufferingLevel = ob_get_level(); - $container = Bootstrap::init($classLoader); + $container = $composerMode + ? TestingBootstrapRunner::init($classLoader) + : Bootstrap::init($classLoader); // Make sure output is not buffered, so command-line output can take place and // phpunit does not whine about changed output bufferings in tests. TYPO3 v14 // opens an implicit output buffer in Bootstrap::init(), TYPO3 v15 does not. diff --git a/Classes/Core/TestingBootstrapPackageCache.php b/Classes/Core/TestingBootstrapPackageCache.php new file mode 100644 index 00000000..759847e3 --- /dev/null +++ b/Classes/Core/TestingBootstrapPackageCache.php @@ -0,0 +1,33 @@ + Date: Sat, 14 Nov 2026 15:08:56 +0100 Subject: [PATCH 2/2] [TASK] Keep classic mode untouched by composer mode instances Composer mode is opt-in, and everyone who does not opt in - TYPO3 v14 and v15 extensions tested from the same branch included - must not notice it. A few parts of the first version reached into classic mode, or into API that extensions build on: * The instance identifier, and with it the instance path and database name, included the mode for classic instances too. It is unchanged now, only composer instances get a suffix. * Paths to link or provide fell back to the original root in classic mode as well, so a wrong path no longer failed. The fallback is limited to composer mode. * Testbase::setUpLocalConfiguration() and setUpBasicTypo3Bootstrap() gained parameters, which is fatal for subclasses overriding them. Both keep their signatures and read the mode themselves. * FunctionalTestCase gained four final protected methods, which are fatal for test cases that already have a method of the same name. The mode check is private now, and the helpers tests need are moved to the new TestInstance class. * TestingBootstrapRunner extends Bootstrap as a readonly class, which is a fatal error before TYPO3 v14.3. Enabling composer mode on an older version now fails with a message instead. Each instance's package set is now written to its own package artifact, the composer mode counterpart of PackageStates.php, and read by TYPO3's ComposerPackageArtifact. That replaces the in-memory package cache and the static state it needed, and the console entry point no longer has to carry the extension list. Package objects are unserialized freshly on every boot, so they can no longer be bound to the wrong instance. The narrowed set keeps the dependency order of the shared installation's artifact. Also: the duplicate exception code is replaced, and the default extension list exists once. --- .../ComposerMode/ComposerModeInstance.php | 283 ++++++++-------- .../ComposerMode/InMemoryPackageCache.php | 56 ---- .../Functional/Framework/TestInstance.php | 84 +++++ .../Core/Functional/FunctionalTestCase.php | 309 ++++++------------ Classes/Core/SystemEnvironmentBuilder.php | 4 +- Classes/Core/Testbase.php | 71 ++-- Classes/Core/TestingBootstrapPackageCache.php | 33 -- Classes/Core/TestingBootstrapRunner.php | 19 +- 8 files changed, 374 insertions(+), 485 deletions(-) delete mode 100644 Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php create mode 100644 Classes/Core/Functional/Framework/TestInstance.php delete mode 100644 Classes/Core/TestingBootstrapPackageCache.php diff --git a/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php index 5c6e1fed..070d47bf 100644 --- a/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php +++ b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php @@ -17,58 +17,85 @@ * The TYPO3 project - inspiring people to share! */ +use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Package\Cache\PackageCacheEntry; -use TYPO3\CMS\Core\Package\Cache\PackageCacheInterface; use TYPO3\TestingFramework\Composer\ComposerPackageManager; use TYPO3\TestingFramework\Core\Exception; /** - * Builds composer mode test instances from a single, shared "superset" installation. + * Builds composer mode test instances from a single, shared composer installation. * - * The superset is a real composer installation containing every system extension and - * every fixture extension, produced once per test run by - * Build/Scripts/setupFunctionalComposerSuperset.php. It exists so that the package - * artifact a test instance boots from is the artifact the real installer plugin built, - * rather than one the testing framework invented. + * Composer mode is opt-in with TYPO3_TESTING_INSTANCE_MODE=composer. Without it, every + * test instance is a classic mode instance, exactly as before. * - * A test instance is *not* a copy of the superset. It is a small directory that borrows - * the superset's vendor tree: + * The shared installation is a real composer installation containing every extension + * any test case activates. TYPO3 Core builds it with + * Build/Scripts/setupFunctionalComposerSuperset.php. It is looked up in + * typo3temp/var/tests/composer-superset below the original root, or wherever + * TYPO3_TESTING_SUPERSET_PATH points to. The package artifact a test instance boots + * from is therefore derived from the one the real installer plugin built, not one the + * testing framework invented. * - * /vendor -> /vendor - * /public/_assets -> /public/_assets + * A test instance is *not* a copy of the shared installation. It is a small directory + * that borrows its vendor tree: + * + * /vendor -> /vendor + * /public/_assets -> /public/_assets * /config/system/… own configuration - * /var, fileadmin, … own state + * /var, public/… own state * * Package paths inside the artifact are relative to the project path, so the vendor - * symlink is what makes them resolve. Classes are *not* loaded from the superset: - * the root autoloader already maps every system extension and every fixture extension, - * so registering a second autoloader - and with it a second copy of every third party - * package - is unnecessary and avoided. + * symlink is what makes them resolve. Classes are *not* loaded from the shared + * installation: the root autoloader already maps every extension, so registering a + * second autoloader - and with it a second copy of every third party package - is + * unnecessary and avoided. * - * Per test case, only the active package set differs. That is expressed by narrowing the - * artifact's configuration, which the package manager treats as "these are active" while - * still knowing about every installed package. + * Per test case, only the active package set differs. It is written into the instance + * as its own package artifact, the composer mode counterpart of the PackageStates.php + * of a classic mode instance. * * @internal */ final class ComposerModeInstance { /** - * Raw artifact data, read from disk once per process. - * - * Deliberately the raw array and not the PackageCacheEntry built from it: see - * getSupersetArtifact(). + * Name of the command line entry point written into a composer mode instance. */ - private static ?array $supersetArtifactData = null; + public const CLI_ENTRY_POINT = 'typo3-testing-cli.php'; + + private const PACKAGE_ARTIFACT_DIRECTORY = 'var/testing-framework'; + private const PACKAGE_ARTIFACT_FILE = 'PackageArtifact.php'; /** - * The artifact currently handed out, and the instance it was built for. + * Whether test instances are provisioned in composer mode. */ - private static ?PackageCacheEntry $supersetArtifact = null; - private static string $supersetArtifactInstancePath = ''; + public static function isEnabled(): bool + { + $mode = (string)getenv('TYPO3_TESTING_INSTANCE_MODE'); + if ($mode === '' || $mode === 'classic') { + return false; + } + if ($mode !== 'composer') { + throw new Exception( + sprintf('TYPO3_TESTING_INSTANCE_MODE must be "classic" or "composer", "%s" given.', $mode), + 1794653447 + ); + } + // TestingBootstrapRunner extends Bootstrap, which is a readonly class since TYPO3 + // v14.3 only. Loading it on an older version is a fatal error rather than a message. + $typo3Version = new Typo3Version(); + if (version_compare($typo3Version->getBranch(), '14.3', '<')) { + throw new Exception( + sprintf('Composer mode functional tests need TYPO3 v14.3 or later, %s is installed.', $typo3Version->getVersion()), + 1794653448 + ); + } + + return true; + } /** - * Absolute path of the shared superset installation. + * Absolute path of the shared composer installation. */ public static function getSupersetPath(): string { @@ -83,24 +110,14 @@ public static function getSupersetPath(): string } /** - * Fails with an actionable message rather than a confusing bootstrap error when the - * superset has not been built. + * Directory holding the package artifact of a composer mode instance, in the form + * TYPO3's ComposerPackageArtifact expects it. + * + * @param non-empty-string $instancePath */ - public static function assertSupersetIsBuilt(): void + public static function getPackageArtifactDirectory(string $instancePath): string { - $artifact = self::getSupersetPath() . '/vendor/typo3/PackageArtifact.php'; - if (!is_file($artifact)) { - throw new Exception( - sprintf( - 'Composer mode functional tests need the shared composer installation, which was' - . ' not found at "%s". Build it with' - . ' Build/Scripts/setupFunctionalComposerSuperset.php, or run the suite through' - . ' runTests.sh, which builds it for you.', - self::getSupersetPath() - ), - 1754092801 - ); - } + return $instancePath . '/' . self::PACKAGE_ARTIFACT_DIRECTORY; } /** @@ -112,6 +129,7 @@ public static function assertSupersetIsBuilt(): void */ public static function provisionInstanceDirectory(string $instancePath, array $additionalFoldersToCreate = []): void { + self::assertSupersetIsBuilt(); $superset = self::getSupersetPath(); // Plain mkdir rather than GeneralUtility::mkdir_deep: this runs before the // instance is bootstrapped, so the global configuration the latter reads its @@ -141,28 +159,86 @@ public static function provisionInstanceDirectory(string $instancePath, array $a } /** - * Name of the command line entry point written into a composer mode instance. + * Writes the package artifact of a test instance: every package of the shared + * installation stays known, only the given ones are active. + * + * The artifact is written once, when the instance is created, and read by every + * bootstrap of the instance - in the test process and in console sub processes alike. + * Each bootstrap unserializes fresh package objects from it. That matters: a package + * resolves its relative path against the project path of whichever instance first + * asks for it and keeps the result, so package objects must never be shared between + * instances. + * + * @param non-empty-string $instancePath + * @param non-empty-string[] $activeExtensionKeys */ - public const CLI_ENTRY_POINT = 'typo3-testing-cli.php'; + public static function writePackageArtifact(string $instancePath, array $activeExtensionKeys): void + { + self::assertSupersetIsBuilt(); + $data = require self::getSupersetArtifactFile(); + if (!is_array($data)) { + throw new Exception('The composer superset package artifact could not be read.', 1754092803); + } + $full = PackageCacheEntry::fromCache($data); + $configuration = $full->getConfiguration(); + $missing = array_diff($activeExtensionKeys, array_keys($configuration['packages'])); + if ($missing !== []) { + throw new Exception( + sprintf( + 'Extension(s) "%s" are not part of the shared composer installation at "%s". It has to' + . ' contain every extension a test case loads, so this usually means it is stale.' + . ' TYPO3 Core rebuilds it with Build/Scripts/setupFunctionalComposerSuperset.php.', + implode('", "', $missing), + self::getSupersetPath() + ), + 1754092802 + ); + } + // Keep the order of the shared artifact, which the installer sorted by dependencies. + // The root package of the shared installation is registered as a package too, and + // TYPO3 expects the app package to be active. + $narrowed = ['version' => $configuration['version'] ?? 5, 'packages' => []]; + foreach ($configuration['packages'] as $key => $packageConfiguration) { + if (in_array($key, $activeExtensionKeys, true) || str_contains((string)$key, '/')) { + $narrowed['packages'][$key] = $packageConfiguration; + } + } + $identifier = sha1(($full->getIdentifier() ?? '') . '|' . implode(',', array_keys($narrowed['packages']))); + $entry = PackageCacheEntry::fromPackageData( + $narrowed, + $full->getAliasMap(), + $full->getComposerNameMap(), + $full->getPackages() + )->withIdentifier($identifier); + + $directory = self::getPackageArtifactDirectory($instancePath); + self::createDirectory($directory); + $result = @file_put_contents( + $directory . '/' . self::PACKAGE_ARTIFACT_FILE, + 'serialize() . ';' . PHP_EOL + ); + if ($result === false) { + throw new Exception('Can not write the package artifact of the test instance.', 1794653449); + } + } /** * Writes the command line entry point a composer mode instance is driven by. * * Tests that run a console command in a sub process cannot use EXT:core/bin/typo3 in * composer mode. That script derives its autoloader from its own __DIR__, and since - * the instance borrows the superset's vendor tree - whose packages are themselves - * symlinks into the core checkout - __DIR__ resolves all the way back to the core - * checkout and the sub process boots with the root autoloader. TYPO3_COMPOSER_MODE is - * then undefined, so the sub process runs in classic mode and scans for packages in a + * the instance borrows the shared vendor tree - whose packages may themselves be + * symlinks to elsewhere - __DIR__ resolves away from the instance and the sub process + * boots with whatever autoloader it finds there. TYPO3_COMPOSER_MODE is then + * undefined, so the sub process runs in classic mode and scans for packages in a * directory the instance does not have. * * The generated script does in a sub process exactly what setUp() does in the test - * process: force composer mode and boot with this instance's narrowed package set. + * process: force composer mode and boot from this instance's package artifact. * * @param non-empty-string $instancePath - * @param non-empty-string[] $activeExtensionKeys */ - public static function writeCliEntryPoint(string $instancePath, array $activeExtensionKeys): void + public static function writeCliEntryPoint(string $instancePath): void { $script = sprintf( <<<'PHP' @@ -177,14 +253,9 @@ public static function writeCliEntryPoint(string $instancePath, array $activeExt // failsafe container. putenv('TYPO3_PATH_APP=' . %s); putenv('TYPO3_PATH_ROOT=' . %s); - putenv('TYPO3_TESTING_SUPERSET_PATH=' . %s); + putenv('TYPO3_TESTING_INSTANCE_MODE=composer'); $_SERVER['PWD'] = %s; $_SERVER['argv'][0] = 'index.php'; - \TYPO3\TestingFramework\Core\TestingBootstrapPackageCache::$packageCache = - \TYPO3\TestingFramework\Core\Functional\Framework\ComposerMode\ComposerModeInstance::createPackageCache( - %s, - %s - ); \TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run( 0, \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_CLI, @@ -196,103 +267,37 @@ public static function writeCliEntryPoint(string $instancePath, array $activeExt ->run() ); PHP, - var_export(self::getRootAutoloadPath(), true), + var_export((new ComposerPackageManager())->getVendorPath() . '/autoload.php', true), var_export($instancePath, true), var_export($instancePath . '/public', true), - var_export(self::getSupersetPath(), true), - var_export($instancePath, true), - var_export($instancePath, true), - var_export($activeExtensionKeys, true) + var_export($instancePath, true) ); file_put_contents($instancePath . '/' . self::CLI_ENTRY_POINT, $script . PHP_EOL); } /** - * The autoloader the test process itself uses - the one that maps every system - * extension and every fixture extension. - */ - private static function getRootAutoloadPath(): string - { - return (new ComposerPackageManager())->getVendorPath() . '/autoload.php'; - } - - /** - * The package cache a test instance boots from: every package of the superset stays - * known, only the given ones are active. - * - * @param non-empty-string $instancePath - * @param non-empty-string[] $activeExtensionKeys + * Fails with an actionable message rather than a confusing bootstrap error when the + * shared installation has not been built. */ - public static function createPackageCache(string $instancePath, array $activeExtensionKeys): PackageCacheInterface + private static function assertSupersetIsBuilt(): void { - $full = self::getSupersetArtifact($instancePath); - $configuration = $full->getConfiguration(); - $narrowed = ['version' => $configuration['version'] ?? 5, 'packages' => []]; - $missing = []; - foreach ($activeExtensionKeys as $key) { - if (!isset($configuration['packages'][$key])) { - $missing[] = $key; - continue; - } - $narrowed['packages'][$key] = $configuration['packages'][$key]; - } - if ($missing !== []) { + if (!is_file(self::getSupersetArtifactFile())) { throw new Exception( sprintf( - 'Extension(s) "%s" are not part of the composer superset installation. Every system' - . ' extension and every fixture extension below Tests/ is installed into it, so this' - . ' usually means the superset is stale - rebuild it with' - . ' Build/Scripts/setupFunctionalComposerSuperset.php.', - implode('", "', $missing) + 'Composer mode functional tests need a shared composer installation, which was' + . ' not found at "%s". Set TYPO3_TESTING_SUPERSET_PATH to point to one. TYPO3 Core' + . ' builds it with Build/Scripts/setupFunctionalComposerSuperset.php, and runTests.sh' + . ' does that for you.', + self::getSupersetPath() ), - 1754092802 + 1754092801 ); } - // The root package of the superset is registered as a package too, and TYPO3 - // expects the app package to be active. - foreach (array_keys($configuration['packages']) as $key) { - if (str_contains((string)$key, '/')) { - $narrowed['packages'][$key] = $configuration['packages'][$key]; - } - } - - return new InMemoryPackageCache( - PackageCacheEntry::fromPackageData( - $narrowed, - $full->getAliasMap(), - $full->getComposerNameMap(), - $full->getPackages() - ) - ); } - /** - * The artifact stores package paths relative to the project path, and Package - * resolves them against Environment::getProjectPath() on first access - once, - * writing the absolute path back into the object. Package objects are therefore - * bound to the instance that first touched them, and reusing one artifact across - * instances makes every later instance look for its packages inside the first - * instance's directory. Rebuild the entry whenever the instance changes; only the - * raw artifact data is kept, and reading it from disk happens once per process. - * - * @param non-empty-string $instancePath - */ - private static function getSupersetArtifact(string $instancePath): PackageCacheEntry + private static function getSupersetArtifactFile(): string { - if (self::$supersetArtifact !== null && self::$supersetArtifactInstancePath === $instancePath) { - return self::$supersetArtifact; - } - if (self::$supersetArtifactData === null) { - self::assertSupersetIsBuilt(); - $data = require self::getSupersetPath() . '/vendor/typo3/PackageArtifact.php'; - if (!is_array($data)) { - throw new Exception('The composer superset package artifact could not be read.', 1754092803); - } - self::$supersetArtifactData = $data; - } - self::$supersetArtifactInstancePath = $instancePath; - - return self::$supersetArtifact = PackageCacheEntry::fromCache(self::$supersetArtifactData); + return self::getSupersetPath() . '/vendor/typo3/' . self::PACKAGE_ARTIFACT_FILE; } private static function createDirectory(string $path): void diff --git a/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php b/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php deleted file mode 100644 index 056df2bf..00000000 --- a/Classes/Core/Functional/Framework/ComposerMode/InMemoryPackageCache.php +++ /dev/null @@ -1,56 +0,0 @@ -entry; - } - - public function store(PackageCacheEntry $cacheEntry): void - { - // Nothing to store: the entry is derived from the superset artifact on every boot. - } - - public function invalidate(): void - { - // Nothing to invalidate for the same reason. - } - - public function getIdentifier(): string - { - return $this->entry->getIdentifier() ?? 'testing-framework-composer-mode'; - } -} diff --git a/Classes/Core/Functional/Framework/TestInstance.php b/Classes/Core/Functional/Framework/TestInstance.php new file mode 100644 index 00000000..3d0b6764 --- /dev/null +++ b/Classes/Core/Functional/Framework/TestInstance.php @@ -0,0 +1,84 @@ +identifier = static::getInstanceIdentifier(); $this->instancePath = static::getInstancePath(); - // TYPO3 derives the layout of an installation from whether the project root and - // the public path differ: same directory means the classic layout with typo3conf - // and typo3temp/var, different directories mean the composer layout with config - // and var next to a public/ document root. + // TYPO3 derives the layout of an installation from whether the project root and the + // public path differ: the same directory means the classic layout, different ones the + // composer layout with config/ and var/ beside a public/ document root. + putenv('TYPO3_PATH_ROOT=' . $this->getDocumentRoot()); putenv('TYPO3_PATH_APP=' . $this->instancePath); - putenv('TYPO3_PATH_ROOT=' . $this->instancePath - . ($this->getInstanceMode() === 'composer' ? '/public' : '')); $testbase = new Testbase(); $testbase->setTypo3TestingContext(); @@ -325,57 +327,36 @@ protected function setUp(): void // Reusing an existing instance. This typically happens for the second, third, ... test // in a test case, so environment is set up only once per test case. GeneralUtility::purgeInstances(); - if ($this->getInstanceMode() === 'composer') { - TestingBootstrapPackageCache::$packageCache = ComposerModeInstance::createPackageCache( - $this->instancePath, - $this->getActiveExtensionKeys() - ); - } - $this->container = $testbase->setUpBasicTypo3Bootstrap( - $this->instancePath, - $this->getInstanceMode() === 'composer' - ); + $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); $this->initializeTestDatabaseAndTruncateTables($testbase, $this->initializeDatabase, $dbPathSqlite, $dbPathSqliteEmpty); $testbase->loadExtensionTables(); } else { - DatabaseSnapshot::initialize(dirname(static::getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); + DatabaseSnapshot::initialize(dirname($this->getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); $testbase->removeOldInstanceIfExists($this->instancePath); - // Basic instance directory structure - $testbase->createDirectory($this->instancePath . '/fileadmin'); - $testbase->createDirectory($this->instancePath . '/typo3temp/var/transient'); - $testbase->createDirectory($this->instancePath . '/typo3temp/assets'); - $testbase->createDirectory($this->instancePath . '/typo3conf/ext'); - // Additionally requested directories - foreach ($this->additionalFoldersToCreate as $directory) { - $testbase->createDirectory($this->instancePath . '/' . $directory); - } - $defaultCoreExtensionsToLoad = [ - 'core', - 'backend', - 'frontend', - 'extbase', - 'fluid', - ]; - $frameworkExtension = [ - 'Resources/Core/Functional/Extensions/json_response', - 'Resources/Core/Functional/Extensions/private_container', - ]; - if ($this->getInstanceMode() === 'composer') { - // Composer mode instances borrow the shared installation's vendor tree + $defaultCoreExtensionsToLoad = self::DEFAULT_CORE_EXTENSIONS; + $frameworkExtension = self::FRAMEWORK_EXTENSIONS; + if (ComposerModeInstance::isEnabled()) { + // Composer mode instances borrow the vendor tree of a shared installation // instead of linking extensions into a classic directory structure. ComposerModeInstance::provisionInstanceDirectory($this->instancePath, $this->additionalFoldersToCreate); - ComposerModeInstance::writeCliEntryPoint($this->instancePath, $this->getActiveExtensionKeys()); + ComposerModeInstance::writeCliEntryPoint($this->instancePath); } else { + // Basic instance directory structure + $testbase->createDirectory($this->instancePath . '/fileadmin'); + $testbase->createDirectory($this->instancePath . '/typo3temp/var/transient'); + $testbase->createDirectory($this->instancePath . '/typo3temp/assets'); + $testbase->createDirectory($this->instancePath . '/typo3conf/ext'); + // Additionally requested directories + foreach ($this->additionalFoldersToCreate as $directory) { + $testbase->createDirectory($this->instancePath . '/' . $directory); + } $testbase->setUpInstanceCoreLinks($this->instancePath, $defaultCoreExtensionsToLoad, $this->coreExtensionsToLoad); $testbase->linkTestExtensionsToInstance($this->instancePath, $this->testExtensionsToLoad); $testbase->linkFrameworkExtensionsToInstance($this->instancePath, $frameworkExtension); } - // Destinations of linked and provided paths are given relative to the document - // root. In classic mode that is the instance directory itself, in composer mode - // it is the public/ directory inside it. - $documentRoot = $this->instancePath . ($this->getInstanceMode() === 'composer' ? '/public' : ''); - $testbase->linkPathsInTestInstance($documentRoot, $this->pathsToLinkInTestInstance); - $testbase->providePathsInTestInstance($documentRoot, $this->pathsToProvideInTestInstance); + // Destinations of linked and provided paths are relative to the document root. + $testbase->linkPathsInTestInstance($this->getDocumentRoot(), $this->pathsToLinkInTestInstance); + $testbase->providePathsInTestInstance($this->getDocumentRoot(), $this->pathsToProvideInTestInstance); $localConfiguration = []; $localConfiguration['DB'] = $testbase->getOriginalDatabaseSettingsFromEnvironmentOrLocalConfiguration(); @@ -455,19 +436,9 @@ protected function setUp(): void $localConfiguration['SYS']['caching']['cacheConfigurations']['imagesizes']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $localConfiguration['SYS']['caching']['cacheConfigurations']['pages']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $localConfiguration['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; - $testbase->setUpLocalConfiguration( - $this->instancePath, - $localConfiguration, - $this->configurationToUseInTestInstance, - $this->getInstanceMode() === 'composer' ? self::SETTINGS_DIR_COMPOSER : self::SETTINGS_DIR_CLASSIC - ); - if ($this->getInstanceMode() === 'composer') { - // No PackageStates in composer mode: the package set comes from the - // artifact of the shared installation, narrowed to this test case. - TestingBootstrapPackageCache::$packageCache = ComposerModeInstance::createPackageCache( - $this->instancePath, - $this->getActiveExtensionKeys() - ); + $testbase->setUpLocalConfiguration($this->instancePath, $localConfiguration, $this->configurationToUseInTestInstance); + if (ComposerModeInstance::isEnabled()) { + ComposerModeInstance::writePackageArtifact($this->instancePath, $this->getActiveExtensionKeys()); } else { $testbase->setUpPackageStates( $this->instancePath, @@ -477,10 +448,7 @@ protected function setUp(): void $frameworkExtension ); } - $this->container = $testbase->setUpBasicTypo3Bootstrap( - $this->instancePath, - $this->getInstanceMode() === 'composer' - ); + $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); $this->initializeTestDatabase( $this->container, $testbase, @@ -502,20 +470,26 @@ protected function setUp(): void protected function tearDown(): void { // Remove any site configuration, and it's cache files, most likely created by SiteBasedTestTrait - if (!in_array('typo3conf/sites', $this->pathsToLinkInTestInstance, true) - && !in_array('typo3conf/sites/', $this->pathsToLinkInTestInstance, true) - && is_dir($this->getSiteConfigurationPath()) - ) { - GeneralUtility::rmdir($this->getSiteConfigurationPath(), true); - } - // The variable path differs by installation layout. Using the classic one in a - // composer mode instance leaves the cache in place, and the next test case sees - // the site configurations of the previous one - or, worse, its emptiness. - $sitesConfigurationCache = $this->instancePath - . ($this->getInstanceMode() === 'composer' ? '/var' : '/typo3temp/var') - . '/cache/code/core/sites-configuration.php'; - if (is_file($sitesConfigurationCache)) { - @unlink($sitesConfigurationCache); + if (ComposerModeInstance::isEnabled()) { + // Linked paths end up below public/, so they can not replace config/sites here. + if (is_dir($this->instancePath . '/config/sites')) { + GeneralUtility::rmdir($this->instancePath . '/config/sites', true); + } + if (is_file($this->instancePath . '/var/cache/code/core/sites-configuration.php')) { + @unlink($this->instancePath . '/var/cache/code/core/sites-configuration.php'); + } + } else { + if (!in_array('typo3conf/sites', $this->pathsToLinkInTestInstance, true) + && !in_array('typo3conf/sites/', $this->pathsToLinkInTestInstance, true) + && is_dir($this->instancePath . '/typo3conf/sites') + ) { + GeneralUtility::rmdir($this->instancePath . '/typo3conf/sites', true); + } + if (file_exists($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php') + && is_file($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php') + ) { + @unlink($this->instancePath . '/typo3temp/var/cache/code/core/sites-configuration.php'); + } } parent::tearDown(); } @@ -640,11 +614,7 @@ protected function getBackendUserRecordFromDatabase(int $userId): ?array private function createServerRequest(string $url, string $method = 'GET'): ServerRequestInterface { $requestUrlParts = parse_url($url); - // The document root is the instance directory only in classic mode. Getting this - // wrong does not fail outright: the site path is then derived from a script that - // is not below the public path, comes out empty, and every generated link silently - // loses its leading slash. - $docRoot = $this->instancePath . ($this->getInstanceMode() === 'composer' ? '/public' : ''); + $docRoot = $this->getDocumentRoot(); $serverParams = [ 'DOCUMENT_ROOT' => $docRoot, @@ -1068,12 +1038,7 @@ private function retrieveFrontendSubRequestResult( $_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'] = $request->getUri()->getHost() ?: 'localhost'; $outputBufferingLevel = ob_get_level(); - // Frontend sub requests bootstrap a second time. In composer mode that bootstrap - // must use the same narrowed package set as the test instance, otherwise it looks - // for a package artifact next to the root installation, where there is none. - $container = $this->getInstanceMode() === 'composer' - ? TestingBootstrapRunner::init(ClassLoadingInformation::getClassLoader()) - : Bootstrap::init(ClassLoadingInformation::getClassLoader()); + $container = TestInstance::boot(); /** @var InternalRequest $serverRequest */ $serverRequest = $request->withAttribute('typo3.testing.context', $context); @@ -1170,136 +1135,56 @@ protected function withDatabaseSnapshot(?callable $createCallback = null, ?calla } /** - * Installation mode the test instance is provisioned in. - * - * TYPO3 ships two installation modes and both need functional test coverage, so the - * mode is chosen per run rather than baked in. Classic mode stays the default; set - * TYPO3_TESTING_INSTANCE_MODE=composer to provision composer mode instances. + * Create a 7 char long hash of class name as identifier. * - * @return 'classic'|'composer' + * @internal + * @return non-empty-string */ - final protected static function getInstanceMode(): string + protected static function getInstanceIdentifier(): string { - $mode = (string)getenv('TYPO3_TESTING_INSTANCE_MODE'); - if ($mode === '' || $mode === 'classic') { - return 'classic'; - } - if ($mode === 'composer') { - return 'composer'; - } - throw new \RuntimeException( - sprintf('TYPO3_TESTING_INSTANCE_MODE must be "classic" or "composer", "%s" given.', $mode), - 1754092805 - ); + // Composer and classic mode instances of the same test case must not collide. + return substr(sha1(static::class . (ComposerModeInstance::isEnabled() ? '|composer' : '')), 0, 7); } /** - * Extension keys that make up this test case's instance, as the package manager knows - * them. - * - * Test cases may name extensions as an extension key, a composer package name or a - * classic mode path, and all three have to end up as the key the artifact is indexed - * by. The default set and the framework's own extensions are always active, matching - * what classic mode writes into PackageStates. - * - * @return non-empty-string[] + * @internal Extensions functional tests should usually not fiddle with this. This may break anytime. + * Checking instance paths within tests is a use case extensions should face seldomly. There + * are usually ways to avoid it. + * @return non-empty-string */ - private function getActiveExtensionKeys(): array + protected static function getInstancePath(): string { - $composerPackageManager = new ComposerPackageManager(); - $keys = ['core', 'backend', 'frontend', 'extbase', 'fluid', 'json_response', 'private_container']; - foreach ([...$this->coreExtensionsToLoad, ...$this->testExtensionsToLoad] as $extension) { - $packageInfo = $composerPackageManager->getPackageInfoWithFallback($extension); - $keys[] = $packageInfo?->getExtensionKey() ?? basename($extension); - } - - return array_values(array_unique($keys)); + $identifier = static::getInstanceIdentifier(); + return ORIGINAL_ROOT . 'typo3temp/var/tests/functional-' . $identifier; } /** - * Boots this test instance again, the way an entry script would. - * - * Tests that exercise an application (the install tool, most notably) bootstrap a - * second container themselves. In composer mode that bootstrap must use this - * instance's narrowed package set: Bootstrap resolves the package artifact from the - * vendor directory of the *root* installation, and the mono repo has none, so calling - * it directly fails with "Package artifact not found". + * The document root of the test instance: the instance directory itself in classic + * mode, its public/ directory in composer mode. */ - final protected function bootTestInstance(bool $failsafe = false): ContainerInterface + private function getDocumentRoot(): string { - return $this->getInstanceMode() === 'composer' - ? TestingBootstrapRunner::init(ClassLoadingInformation::getClassLoader(), $failsafe) - : Bootstrap::init(ClassLoadingInformation::getClassLoader(), $failsafe); + return $this->instancePath . (ComposerModeInstance::isEnabled() ? '/public' : ''); } /** - * Path of the document root relative to the project root, with a trailing slash, or - * an empty string when the two are the same directory. + * Extension keys that make up this test case's instance, as the package manager knows + * them. The default set and the framework's own extensions are always active, as in + * the PackageStates.php of a classic mode instance. * - * A "PKG:typo3/app:…" resource identifier carries a path relative to the *project - * root*, which is the document root only in classic mode - in composer mode the same - * file lives one level deeper, below the web directory. Tests naming such resources - * have to prefix them with this rather than assuming either layout. + * @return non-empty-string[] */ - final protected function getAppRelativePublicPath(): string + private function getActiveExtensionKeys(): array { - if (Environment::getPublicPath() === Environment::getProjectPath()) { - return ''; + $composerPackageManager = new ComposerPackageManager(); + $keys = self::DEFAULT_CORE_EXTENSIONS; + foreach (self::FRAMEWORK_EXTENSIONS as $extensionPath) { + $keys[] = basename($extensionPath); } - - return substr(Environment::getPublicPath(), strlen(Environment::getProjectPath()) + 1) . '/'; - } - - /** - * Absolute path of the PHP script a test uses to run a console command in a sub - * process against this test instance. - * - * Classic mode instances use the core binary directly. Composer mode instances - * cannot: see ComposerModeInstance::writeCliEntryPoint(). - * - * @return non-empty-string - */ - final protected function getCliEntryPoint(): string - { - if ($this->getInstanceMode() === 'composer') { - return $this->instancePath . '/' . ComposerModeInstance::CLI_ENTRY_POINT; + foreach ([...$this->coreExtensionsToLoad, ...$this->testExtensionsToLoad] as $extension) { + $keys[] = $composerPackageManager->getPackageInfoWithFallback($extension)?->getExtensionKey() ?? basename($extension); } - return $this->instancePath . '/typo3/sysext/core/bin/typo3'; - } - - /** - * Where site configurations live in this instance. Classic mode keeps them below - * typo3conf, composer mode below config. - */ - private function getSiteConfigurationPath(): string - { - return $this->instancePath . '/' - . ($this->getInstanceMode() === 'composer' ? 'config' : 'typo3conf') - . '/sites'; - } - - /** - * Identifier of the test instance. The instance directory and the database name - * are derived from it. Composer mode and classic mode instances of the same test - * case must not collide, so the mode is part of it. - * - * @internal Extensions functional tests should usually not fiddle with this. This may break anytime. - * @return non-empty-string - */ - protected static function getInstanceIdentifier(): string - { - return substr(sha1(static::class . '|' . static::getInstanceMode()), 0, 7); - } - - /** - * @internal Extensions functional tests should usually not fiddle with this. This may break anytime. - * Checking instance paths within tests is a use case extensions should face seldomly. There - * are usually ways to avoid it. - * @return non-empty-string - */ - protected static function getInstancePath(): string - { - return ORIGINAL_ROOT . 'typo3temp/var/tests/functional-' . static::getInstanceIdentifier(); + return array_values(array_unique($keys)); } } diff --git a/Classes/Core/SystemEnvironmentBuilder.php b/Classes/Core/SystemEnvironmentBuilder.php index e699fa69..9040611b 100644 --- a/Classes/Core/SystemEnvironmentBuilder.php +++ b/Classes/Core/SystemEnvironmentBuilder.php @@ -54,7 +54,7 @@ public static function run(int $entryPointLevel = 0, int $requestType = 0, ?bool Environment::getPublicPath(), Environment::getVarPath(), Environment::getConfigPath(), - static::determineCurrentScript(), + self::determineCurrentScript(), Environment::isWindows() ? 'WINDOWS' : 'UNIX' ); } @@ -75,7 +75,7 @@ public static function run(int $entryPointLevel = 0, int $requestType = 0, ?bool * silently loses its leading slash. Point at the front end entry script instead, * which is what a web request would have and what classic mode already gets. */ - protected static function determineCurrentScript(): string + private static function determineCurrentScript(): string { if (!Environment::isCli() || !static::usesComposerClassLoading()) { return Environment::getCurrentScript(); diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 72e1336a..816bad6d 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -39,6 +39,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Composer\ComposerPackageManager; use TYPO3\TestingFramework\Composer\PackageInfo; +use TYPO3\TestingFramework\Core\Functional\Framework\ComposerMode\ComposerModeInstance; /** * This is a helper class used by unit and functional test environment builders. @@ -400,16 +401,10 @@ public function linkFrameworkExtensionsToInstance(string $instancePath, array $e public function linkPathsInTestInstance(string $instancePath, array $pathsToLinkInTestInstance): void { foreach ($pathsToLinkInTestInstance as $sourcePathToLinkInTestInstance => $destinationPathToLinkInTestInstance) { - $sourcePath = $instancePath . '/' . ltrim($sourcePathToLinkInTestInstance, '/'); - if (!file_exists($sourcePath)) { - // See providePathsInTestInstance(): sources are given relative to the - // document root, which only carries the extension tree in classic mode. - $sourcePath = rtrim(ORIGINAL_ROOT, '/') . '/' . ltrim($sourcePathToLinkInTestInstance, '/'); - } + $sourcePath = $this->resolveSourcePathInTestInstance($instancePath, $sourcePathToLinkInTestInstance); if (!file_exists($sourcePath)) { throw new Exception( - 'Path ' . $sourcePathToLinkInTestInstance . ' not found, neither in the test instance' - . ' nor below ' . ORIGINAL_ROOT, + 'Path ' . $sourcePath . ' not found', 1476109221 ); } @@ -439,17 +434,10 @@ public function linkPathsInTestInstance(string $instancePath, array $pathsToLink public function providePathsInTestInstance(string $instancePath, array $pathsToProvideInTestInstance): void { foreach ($pathsToProvideInTestInstance as $sourceIdentifier => $designationIdentifier) { - $sourcePath = $instancePath . '/' . ltrim($sourceIdentifier, '/'); - if (!file_exists($sourcePath)) { - // Sources are given relative to the instance, which works in classic mode - // because the extensions are linked into it. A composer mode instance has - // no such tree, so fall back to the same path below the original root. - $sourcePath = rtrim(ORIGINAL_ROOT, '/') . '/' . ltrim($sourceIdentifier, '/'); - } + $sourcePath = $this->resolveSourcePathInTestInstance($instancePath, $sourceIdentifier); if (!file_exists($sourcePath)) { throw new Exception( - 'Path ' . $sourceIdentifier . ' not found, neither in the test instance nor below ' - . ORIGINAL_ROOT, + 'Path ' . $sourcePath . ' not found', 1511956084 ); } @@ -473,6 +461,21 @@ public function providePathsInTestInstance(string $instancePath, array $pathsToP } } + /** + * Sources of linked and provided paths are given relative to the document root of a + * classic mode instance, which carries the extension tree. A composer mode instance has + * none, so there the same path is looked up below the original root. + */ + private function resolveSourcePathInTestInstance(string $instancePath, string $source): string + { + $sourcePath = $instancePath . '/' . ltrim($source, '/'); + if (!file_exists($sourcePath) && ComposerModeInstance::isEnabled()) { + return rtrim(ORIGINAL_ROOT, '/') . '/' . ltrim($source, '/'); + } + + return $sourcePath; + } + /** * Database settings for functional tests can be either set by * environment variables (recommended). The method fetches these. @@ -572,25 +575,18 @@ public function testDatabaseNameIsNotTooLong(string $originalDatabaseName, array * @param array $overruleConfiguration Overrule factory and base configuration * @throws Exception */ - /** - * @param non-empty-string $relativeConfigurationPath Where TYPO3 expects its settings file, - * relative to the instance root. Classic mode instances keep it below typo3conf, - * composer mode instances below config. - */ - public function setUpLocalConfiguration( - string $instancePath, - array $configuration, - array $overruleConfiguration, - string $relativeConfigurationPath = 'typo3conf/system' - ): void { + public function setUpLocalConfiguration(string $instancePath, array $configuration, array $overruleConfiguration): void + { // Base of final LocalConfiguration is core factory configuration $coreExtensionPath = $this->composerPackageManager->getPackageInfo('typo3/cms-core')?->getRealPath() ?? ''; $finalConfigurationArray = require $coreExtensionPath . '/Configuration/FactoryConfiguration.php'; $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $configuration); $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $overruleConfiguration); - $this->createDirectory($instancePath . '/' . trim($relativeConfigurationPath, '/')); + // A composer mode installation keeps its configuration beside the document root. + $configurationPath = $instancePath . (ComposerModeInstance::isEnabled() ? '/config/system' : '/typo3conf/system'); + $this->createDirectory($configurationPath); $result = @file_put_contents( - $instancePath . '/' . trim($relativeConfigurationPath, '/') . '/settings.php', + $configurationPath . '/settings.php', 'getPackagesPath() . '/autoload.php'; + $composerMode = ComposerModeInstance::isEnabled(); SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, $composerMode); $outputBufferingLevel = ob_get_level(); - $container = $composerMode - ? TestingBootstrapRunner::init($classLoader) - : Bootstrap::init($classLoader); + $container = $composerMode ? TestingBootstrapRunner::init($classLoader) : Bootstrap::init($classLoader); // Make sure output is not buffered, so command-line output can take place and // phpunit does not whine about changed output bufferings in tests. TYPO3 v14 // opens an implicit output buffer in Bootstrap::init(), TYPO3 v15 does not. diff --git a/Classes/Core/TestingBootstrapPackageCache.php b/Classes/Core/TestingBootstrapPackageCache.php deleted file mode 100644 index 759847e3..00000000 --- a/Classes/Core/TestingBootstrapPackageCache.php +++ /dev/null @@ -1,33 +0,0 @@ -