diff --git a/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php new file mode 100644 index 00000000..070d47bf --- /dev/null +++ b/Classes/Core/Functional/Framework/ComposerMode/ComposerModeInstance.php @@ -0,0 +1,322 @@ +/vendor -> /vendor + * /public/_assets -> /public/_assets + * /config/system/… own configuration + * /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 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. 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 +{ + /** + * Name of the command line entry point written into a composer mode instance. + */ + public const CLI_ENTRY_POINT = 'typo3-testing-cli.php'; + + private const PACKAGE_ARTIFACT_DIRECTORY = 'var/testing-framework'; + private const PACKAGE_ARTIFACT_FILE = 'PackageArtifact.php'; + + /** + * Whether test instances are provisioned in composer mode. + */ + 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 composer 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'; + } + + /** + * 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 getPackageArtifactDirectory(string $instancePath): string + { + return $instancePath . '/' . self::PACKAGE_ARTIFACT_DIRECTORY; + } + + /** + * 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 + { + 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 + // 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'); + } + } + + /** + * 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 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 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 from this instance's package artifact. + * + * @param non-empty-string $instancePath + */ + public static function writeCliEntryPoint(string $instancePath): void + { + $script = sprintf( + <<<'PHP' + get(\TYPO3\CMS\Core\Console\CommandApplication::class) + ->run() + ); + PHP, + var_export((new ComposerPackageManager())->getVendorPath() . '/autoload.php', true), + var_export($instancePath, true), + var_export($instancePath . '/public', true), + var_export($instancePath, true) + ); + file_put_contents($instancePath . '/' . self::CLI_ENTRY_POINT, $script . PHP_EOL); + } + + /** + * Fails with an actionable message rather than a confusing bootstrap error when the + * shared installation has not been built. + */ + private static function assertSupersetIsBuilt(): void + { + if (!is_file(self::getSupersetArtifactFile())) { + throw new Exception( + sprintf( + '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() + ), + 1754092801 + ); + } + } + + private static function getSupersetArtifactFile(): string + { + return self::getSupersetPath() . '/vendor/typo3/' . self::PACKAGE_ARTIFACT_FILE; + } + + 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/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(); - putenv('TYPO3_PATH_ROOT=' . $this->instancePath); + // 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); $testbase = new Testbase(); @@ -314,31 +333,30 @@ protected function setUp(): void } else { 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 = 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); + } 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); } - $defaultCoreExtensionsToLoad = [ - 'core', - 'backend', - 'frontend', - 'extbase', - 'fluid', - ]; - $frameworkExtension = [ - '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); + // 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(); @@ -419,13 +437,17 @@ protected function setUp(): void $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( - $this->instancePath, - $defaultCoreExtensionsToLoad, - $this->coreExtensionsToLoad, - $this->testExtensionsToLoad, - $frameworkExtension - ); + if (ComposerModeInstance::isEnabled()) { + ComposerModeInstance::writePackageArtifact($this->instancePath, $this->getActiveExtensionKeys()); + } else { + $testbase->setUpPackageStates( + $this->instancePath, + $defaultCoreExtensionsToLoad, + $this->coreExtensionsToLoad, + $this->testExtensionsToLoad, + $frameworkExtension + ); + } $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); $this->initializeTestDatabase( $this->container, @@ -448,16 +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->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'); + 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(); } @@ -582,7 +614,7 @@ protected function getBackendUserRecordFromDatabase(int $userId): ?array private function createServerRequest(string $url, string $method = 'GET'): ServerRequestInterface { $requestUrlParts = parse_url($url); - $docRoot = $this->instancePath; + $docRoot = $this->getDocumentRoot(); $serverParams = [ 'DOCUMENT_ROOT' => $docRoot, @@ -1006,7 +1038,7 @@ private function retrieveFrontendSubRequestResult( $_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'] = $request->getUri()->getHost() ?: 'localhost'; $outputBufferingLevel = ob_get_level(); - $container = Bootstrap::init(ClassLoadingInformation::getClassLoader()); + $container = TestInstance::boot(); /** @var InternalRequest $serverRequest */ $serverRequest = $request->withAttribute('typo3.testing.context', $context); @@ -1110,7 +1142,8 @@ protected function withDatabaseSnapshot(?callable $createCallback = null, ?calla */ protected static function getInstanceIdentifier(): string { - return substr(sha1(static::class), 0, 7); + // Composer and classic mode instances of the same test case must not collide. + return substr(sha1(static::class . (ComposerModeInstance::isEnabled() ? '|composer' : '')), 0, 7); } /** @@ -1124,4 +1157,34 @@ protected static function getInstancePath(): string $identifier = static::getInstanceIdentifier(); return ORIGINAL_ROOT . 'typo3temp/var/tests/functional-' . $identifier; } + + /** + * The document root of the test instance: the instance directory itself in classic + * mode, its public/ directory in composer mode. + */ + private function getDocumentRoot(): string + { + return $this->instancePath . (ComposerModeInstance::isEnabled() ? '/public' : ''); + } + + /** + * 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. + * + * @return non-empty-string[] + */ + private function getActiveExtensionKeys(): array + { + $composerPackageManager = new ComposerPackageManager(); + $keys = self::DEFAULT_CORE_EXTENSIONS; + foreach (self::FRAMEWORK_EXTENSIONS as $extensionPath) { + $keys[] = basename($extensionPath); + } + foreach ([...$this->coreExtensionsToLoad, ...$this->testExtensionsToLoad] as $extension) { + $keys[] = $composerPackageManager->getPackageInfoWithFallback($extension)?->getExtensionKey() ?? basename($extension); + } + + return array_values(array_unique($keys)); + } } diff --git a/Classes/Core/SystemEnvironmentBuilder.php b/Classes/Core/SystemEnvironmentBuilder.php index 65199fda..9040611b 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(), + self::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. + */ + private 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..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,7 +401,7 @@ public function linkFrameworkExtensionsToInstance(string $instancePath, array $e public function linkPathsInTestInstance(string $instancePath, array $pathsToLinkInTestInstance): void { foreach ($pathsToLinkInTestInstance as $sourcePathToLinkInTestInstance => $destinationPathToLinkInTestInstance) { - $sourcePath = $instancePath . '/' . ltrim($sourcePathToLinkInTestInstance, '/'); + $sourcePath = $this->resolveSourcePathInTestInstance($instancePath, $sourcePathToLinkInTestInstance); if (!file_exists($sourcePath)) { throw new Exception( 'Path ' . $sourcePath . ' not found', @@ -433,7 +434,7 @@ public function linkPathsInTestInstance(string $instancePath, array $pathsToLink public function providePathsInTestInstance(string $instancePath, array $pathsToProvideInTestInstance): void { foreach ($pathsToProvideInTestInstance as $sourceIdentifier => $designationIdentifier) { - $sourcePath = $instancePath . '/' . ltrim($sourceIdentifier, '/'); + $sourcePath = $this->resolveSourcePathInTestInstance($instancePath, $sourceIdentifier); if (!file_exists($sourcePath)) { throw new Exception( 'Path ' . $sourcePath . ' not found', @@ -460,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. @@ -566,9 +582,11 @@ public function setUpLocalConfiguration(string $instancePath, array $configurati $finalConfigurationArray = require $coreExtensionPath . '/Configuration/FactoryConfiguration.php'; $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $configuration); $finalConfigurationArray = array_replace_recursive($finalConfigurationArray, $overruleConfiguration); - $this->createDirectory($instancePath . '/typo3conf/system'); + // 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 . '/typo3conf/system/settings.php', + $configurationPath . '/settings.php', 'getPackagesPath() . '/autoload.php'; - SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, false); + $composerMode = ComposerModeInstance::isEnabled(); + 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/TestingBootstrapRunner.php b/Classes/Core/TestingBootstrapRunner.php new file mode 100644 index 00000000..49b7eae2 --- /dev/null +++ b/Classes/Core/TestingBootstrapRunner.php @@ -0,0 +1,53 @@ +