From f577ab3c6104a76e4eaac51ce7465406d41366a4 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Fri, 21 Jun 2024 17:48:43 +0200 Subject: [PATCH 01/29] [TASK] Remove scheduled workflow from branch `8` --- .github/workflows/nightly-7.yml | 23 ----------------------- .github/workflows/nightly-main.yml | 23 ----------------------- 2 files changed, 46 deletions(-) delete mode 100644 .github/workflows/nightly-7.yml delete mode 100644 .github/workflows/nightly-main.yml diff --git a/.github/workflows/nightly-7.yml b/.github/workflows/nightly-7.yml deleted file mode 100644 index b9f081d6..00000000 --- a/.github/workflows/nightly-7.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: "nightly-7" -on: - schedule: - - cron: '42 5 * * *' - workflow_dispatch: - -jobs: - nightly-7: - name: "dispatch-nightly-7" - runs-on: ubuntu-22.04 - permissions: write-all - steps: - - name: Checkout '7' - uses: actions/checkout@v4 - with: - ref: '7' - - - name: Execute 'ci.yml' on '7' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run ci.yml --ref 7 diff --git a/.github/workflows/nightly-main.yml b/.github/workflows/nightly-main.yml deleted file mode 100644 index 423e9a5b..00000000 --- a/.github/workflows/nightly-main.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: "nightly-main" -on: - schedule: - - cron: '42 5 * * *' - workflow_dispatch: - -jobs: - nightly-main: - name: "dispatch-nightly-main" - runs-on: ubuntu-22.04 - permissions: write-all - steps: - - name: Checkout 'main' - uses: actions/checkout@v4 - with: - ref: 'main' - - - name: Execute 'ci.yml' on 'main' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run ci.yml --ref main From 7bf4faeb3832d81204cea4d355716bf8a3ebb5e3 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Fri, 21 Jun 2024 18:16:15 +0200 Subject: [PATCH 02/29] [TASK] php-cs-fixer scans again (#570) --path-mode intersection without default option leads to php-cs-fixer scanning nothing. Remove option and just let scan everything always. --- Build/Scripts/runTests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 40a52cbb..d97ebf4f 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -210,7 +210,7 @@ case ${TEST_SUITE} in if [[ ! -z ${CGLCHECK_DRY_RUN} ]]; then CGLCHECK_DRY_RUN="--dry-run --diff" fi - COMMAND="php -dxdebug.mode=off .Build/bin/php-cs-fixer fix -v ${CGLCHECK_DRY_RUN} --path-mode intersection --config=Build/php-cs-fixer/config.php" + COMMAND="php -dxdebug.mode=off .Build/bin/php-cs-fixer fix -v ${CGLCHECK_DRY_RUN} --config=Build/php-cs-fixer/config.php" ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name cgl-${SUFFIX} ${IMAGE_PHP} ${COMMAND} SUITE_EXIT_CODE=$? ;; From 35a00d0f74f9b3188dd58136a4e35154aa6641da Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Mon, 24 Jun 2024 14:20:35 +0200 Subject: [PATCH 03/29] [TASK] runTests.sh removes networks after run (#585) Fix cleanUp() to properly shut down networks after run. --- Build/Scripts/runTests.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index d97ebf4f..2ddb1502 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash cleanUp() { - ATTACHED_CONTAINERS=$(${CONTAINER_BIN} ps --filter network=${NETWORK} --format='{{.Names}} 2>/dev/null') - if [[ -n $ATTACHED_CONTAINERS ]]; then - for ATTACHED_CONTAINER in ${ATTACHED_CONTAINERS}; do - ${CONTAINER_BIN} kill ${ATTACHED_CONTAINER} >/dev/null - done + ATTACHED_CONTAINERS=$(${CONTAINER_BIN} ps --filter network=${NETWORK} --format='{{.Names}}') + for ATTACHED_CONTAINER in ${ATTACHED_CONTAINERS}; do + ${CONTAINER_BIN} kill ${ATTACHED_CONTAINER} >/dev/null + done + if [ ${CONTAINER_BIN} = "docker" ]; then ${CONTAINER_BIN} network rm ${NETWORK} >/dev/null + else + ${CONTAINER_BIN} network rm -f ${NETWORK} >/dev/null fi } From f508b8e83628e6769479dc5112f2fa072f6e0789 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Wed, 26 Jun 2024 00:49:26 +0200 Subject: [PATCH 04/29] [TASK] Add InstructionInterface (#599) Have an interface to avoid type hinting AbstractInstruction. Releases: main, 8 --- .../Frontend/Internal/AbstractInstruction.php | 2 +- .../Internal/ArrayValueInstruction.php | 2 +- .../Internal/InstructionInterface.php | 23 +++++++++++++++++++ .../Internal/TypoScriptInstruction.php | 2 +- .../Framework/Frontend/InternalRequest.php | 6 ++--- 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 Classes/Core/Functional/Framework/Frontend/Internal/InstructionInterface.php diff --git a/Classes/Core/Functional/Framework/Frontend/Internal/AbstractInstruction.php b/Classes/Core/Functional/Framework/Frontend/Internal/AbstractInstruction.php index 9f747800..12006742 100644 --- a/Classes/Core/Functional/Framework/Frontend/Internal/AbstractInstruction.php +++ b/Classes/Core/Functional/Framework/Frontend/Internal/AbstractInstruction.php @@ -18,7 +18,7 @@ namespace TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal; /** - * @todo: Turn into an interface and drop abstract. + * @deprecated: Will be removed with TF 9. Use InstructionInterface for type hints. */ abstract class AbstractInstruction { diff --git a/Classes/Core/Functional/Framework/Frontend/Internal/ArrayValueInstruction.php b/Classes/Core/Functional/Framework/Frontend/Internal/ArrayValueInstruction.php index c63c14b3..4b45219d 100644 --- a/Classes/Core/Functional/Framework/Frontend/Internal/ArrayValueInstruction.php +++ b/Classes/Core/Functional/Framework/Frontend/Internal/ArrayValueInstruction.php @@ -20,7 +20,7 @@ /** * Model of arbitrary array value instruction */ -class ArrayValueInstruction extends AbstractInstruction +class ArrayValueInstruction extends AbstractInstruction implements InstructionInterface { protected array $array = []; diff --git a/Classes/Core/Functional/Framework/Frontend/Internal/InstructionInterface.php b/Classes/Core/Functional/Framework/Frontend/Internal/InstructionInterface.php new file mode 100644 index 00000000..aded3491 --- /dev/null +++ b/Classes/Core/Functional/Framework/Frontend/Internal/InstructionInterface.php @@ -0,0 +1,23 @@ +withAttribute('testing-framework-instructions', $currentAttribute); } - public function getInstruction(string $identifier): ?AbstractInstruction + public function getInstruction(string $identifier): ?InstructionInterface { $currentAttribute = $this->getAttribute('testing-framework-instructions', []); return $currentAttribute[$identifier] ?? null; From d4a6bd9f478436527d216d566f2979c8ef0a6771 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Sat, 10 Aug 2024 11:13:57 +0200 Subject: [PATCH 05/29] [TASK] Create DataHandler using GU::makeInstance() (#602) (#605) To prepare DH for DI, consumers must use core instance factory method instead of new(). --- .../Framework/DataHandling/Scenario/DataHandlerWriter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php index a3dcbd87..69813e26 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php +++ b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php @@ -19,6 +19,7 @@ use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Utility\GeneralUtility; class DataHandlerWriter { @@ -44,7 +45,7 @@ class DataHandlerWriter public static function withBackendUser( BackendUserAuthentication $backendUser ): self { - $dataHandler = new DataHandler(); + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); if (isset($backendUser->uc['copyLevels'])) { $dataHandler->copyTree = $backendUser->uc['copyLevels']; } From 02934685e3d2595b08335a8ea424021e9ab91e72 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Sat, 10 Aug 2024 11:37:36 +0200 Subject: [PATCH 06/29] [TASK] Avoid implicitly nullable method parameter (#607) Implicit nullable method parameters are deprecated with PHP 8.4. The patch prepares affected method signatures and activates an according php-cs-fixer rule. --- Build/php-cs-fixer/config.php | 4 ++++ Classes/Composer/ComposerPackageManager.php | 2 +- Classes/Core/BaseTestCase.php | 2 +- .../Framework/DataHandling/ActionService.php | 6 +++--- .../DataHandling/Scenario/DataHandlerFactory.php | 16 ++++++++-------- .../Functional/Framework/Frontend/Collector.php | 6 +++--- .../Functional/Framework/Frontend/Renderer.php | 6 +++--- .../Framework/Frontend/ResponseContent.php | 2 +- Classes/Core/Functional/FunctionalTestCase.php | 2 +- Classes/Core/PackageCollection.php | 6 +++--- 10 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Build/php-cs-fixer/config.php b/Build/php-cs-fixer/config.php index 57ebfa08..755b1ae4 100644 --- a/Build/php-cs-fixer/config.php +++ b/Build/php-cs-fixer/config.php @@ -56,6 +56,10 @@ 'no_unused_imports' => true, 'no_useless_else' => true, 'no_useless_nullsafe_operator' => true, + 'nullable_type_declaration' => [ + 'syntax' => 'question_mark', + ], + 'nullable_type_declaration_for_default_null_value' => true, 'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'], 'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']], 'php_unit_mock_short_will_return' => true, diff --git a/Classes/Composer/ComposerPackageManager.php b/Classes/Composer/ComposerPackageManager.php index c06015af..467783b4 100644 --- a/Classes/Composer/ComposerPackageManager.php +++ b/Classes/Composer/ComposerPackageManager.php @@ -47,7 +47,7 @@ final class ComposerPackageManager private static string $publicPath = ''; - private static PackageInfo|null $rootPackage = null; + private static ?PackageInfo $rootPackage = null; /** * @var array diff --git a/Classes/Core/BaseTestCase.php b/Classes/Core/BaseTestCase.php index 5ae2a0e4..37a9c44c 100644 --- a/Classes/Core/BaseTestCase.php +++ b/Classes/Core/BaseTestCase.php @@ -108,7 +108,7 @@ protected function tearDown(): void */ protected function getAccessibleMock( string $originalClassName, - array|null $methods = [], + ?array $methods = [], array $arguments = [], string $mockClassName = '', bool $callOriginalConstructor = true, diff --git a/Classes/Core/Functional/Framework/DataHandling/ActionService.php b/Classes/Core/Functional/Framework/DataHandling/ActionService.php index 210d99b7..79f67354 100644 --- a/Classes/Core/Functional/Framework/DataHandling/ActionService.php +++ b/Classes/Core/Functional/Framework/DataHandling/ActionService.php @@ -111,7 +111,7 @@ public function createNewRecords(int $pageId, array $tableRecordData): array * modifyRecord('tt_content', 42, ['hidden' => '1']); // Modify a single record * modifyRecord('tt_content', 42, ['hidden' => '1'], ['tx_irre_table' => [4]]); // Modify a record and delete a child */ - public function modifyRecord(string $tableName, int $uid, array $recordData, array $deleteTableRecordIds = null) + public function modifyRecord(string $tableName, int $uid, array $recordData, ?array $deleteTableRecordIds = null) { $dataMap = [ $tableName => [ @@ -266,7 +266,7 @@ public function clearWorkspaceRecords(array $tableRecordIds) * Example: * copyRecord('tt_content', 42, 5, ['header' => 'Testing #1']); */ - public function copyRecord(string $tableName, int $uid, int $pageId, array $recordData = null): array + public function copyRecord(string $tableName, int $uid, int $pageId, ?array $recordData = null): array { $commandMap = [ $tableName => [ @@ -302,7 +302,7 @@ public function copyRecord(string $tableName, int $uid, int $pageId, array $reco * @param array $recordData Additional record data to change when moving. * @return array */ - public function moveRecord(string $tableName, int $uid, int $targetUid, array $recordData = null): array + public function moveRecord(string $tableName, int $uid, int $targetUid, ?array $recordData = null): array { $commandMap = [ $tableName => [ diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php index 3f86913c..04b8fb0d 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php +++ b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php @@ -110,8 +110,8 @@ public function getSuggestedIds(): array */ private function processEntities( array $settings, - string $nodeId = null, - string $parentId = null + ?string $nodeId = null, + ?string $parentId = null ): void { foreach ($settings as $entityName => $entitySettings) { $entityConfiguration = $this->provideEntityConfiguration($entityName); @@ -135,8 +135,8 @@ private function processEntities( private function processEntityItem( EntityConfiguration $entityConfiguration, array $itemSettings, - string $nodeId = null, - string $parentId = null + ?string $nodeId = null, + ?string $parentId = null ): void { $values = $this->processEntityValues( $entityConfiguration, @@ -199,7 +199,7 @@ private function processLanguageVariantItem( EntityConfiguration $entityConfiguration, array $itemSettings, array $ancestorIds, - string $nodeId = null + ?string $nodeId = null ): void { $values = $this->processEntityValues( $entityConfiguration, @@ -240,7 +240,7 @@ private function processVersionVariantItem( EntityConfiguration $entityConfiguration, array $itemSettings, string $ancestorId, - string $nodeId = null + ?string $nodeId = null ): void { if (isset($itemSettings['self'])) { throw new \LogicException( @@ -284,8 +284,8 @@ private function processVersionVariantItem( private function processEntityValues( EntityConfiguration $entityConfiguration, array $itemSettings, - string $nodeId = null, - string $parentId = null + ?string $nodeId = null, + ?string $parentId = null ): array { if (isset($itemSettings['self']) && isset($itemSettings['version'])) { throw new \LogicException( diff --git a/Classes/Core/Functional/Framework/Frontend/Collector.php b/Classes/Core/Functional/Framework/Frontend/Collector.php index 135e8bcb..bd5465b4 100644 --- a/Classes/Core/Functional/Framework/Frontend/Collector.php +++ b/Classes/Core/Functional/Framework/Frontend/Collector.php @@ -44,7 +44,7 @@ public function setContentObjectRenderer(ContentObjectRenderer $cObj): void $this->cObj = $cObj; } - public function addRecordData($content, array $configuration = null, ServerRequestInterface $request): void + public function addRecordData($content, ?array $configuration = null, ?ServerRequestInterface $request = null): void { $recordIdentifier = $this->cObj->currentRecord; [$tableName] = explode(':', $recordIdentifier); @@ -63,7 +63,7 @@ public function addRecordData($content, array $configuration = null, ServerReque } } - public function addFileData($content, array $configuration = null, ServerRequestInterface $request): void + public function addFileData($content, ?array $configuration = null, ?ServerRequestInterface $request = null): void { $currentFile = $this->cObj->getCurrentFile(); @@ -84,7 +84,7 @@ public function addFileData($content, array $configuration = null, ServerRequest $this->addToStructure($levelIdentifier, $recordIdentifier, $recordData); } - public function attachSection(string $content, array $configuration = null): void + public function attachSection(string $content, ?array $configuration = null): void { $section = [ 'structure' => $this->structure, diff --git a/Classes/Core/Functional/Framework/Frontend/Renderer.php b/Classes/Core/Functional/Framework/Frontend/Renderer.php index e0870daa..5468576a 100644 --- a/Classes/Core/Functional/Framework/Frontend/Renderer.php +++ b/Classes/Core/Functional/Framework/Frontend/Renderer.php @@ -40,7 +40,7 @@ class Renderer implements SingletonInterface * @param string $content * @param array|null $configuration */ - public function parseValues($content, array $configuration = null) + public function parseValues($content, ?array $configuration = null) { if (empty($content)) { return; @@ -84,7 +84,7 @@ public function parseValues($content, array $configuration = null) * @param string $content * @param array|null $configuration */ - public function renderValues($content, array $configuration = null) + public function renderValues($content, ?array $configuration = null) { if (empty($configuration['values.'])) { return; @@ -112,7 +112,7 @@ public function addSection(array $section, $as = null) * @param array|null $configuration * @return string */ - public function renderSections($content, array $configuration = null) + public function renderSections($content, ?array $configuration = null) { return json_encode($this->sections); } diff --git a/Classes/Core/Functional/Framework/Frontend/ResponseContent.php b/Classes/Core/Functional/Framework/Frontend/ResponseContent.php index ad66cc96..a921df46 100644 --- a/Classes/Core/Functional/Framework/Frontend/ResponseContent.php +++ b/Classes/Core/Functional/Framework/Frontend/ResponseContent.php @@ -47,7 +47,7 @@ class ResponseContent final public function __construct() {} - public static function fromString(string $data, ResponseContent $target = null): ResponseContent + public static function fromString(string $data, ?ResponseContent $target = null): ResponseContent { $target = $target ?? new static(); $content = json_decode($data, true); diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index 58edd8e2..45ca7044 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -898,7 +898,7 @@ protected function addTypoScriptToTemplateRecord(int $pageId, string $typoScript */ protected function executeFrontendSubRequest( InternalRequest $request, - InternalRequestContext $context = null, + ?InternalRequestContext $context = null, bool $followRedirects = false ): ResponseInterface { if ($context === null) { diff --git a/Classes/Core/PackageCollection.php b/Classes/Core/PackageCollection.php index 6a3ff288..cbef267e 100644 --- a/Classes/Core/PackageCollection.php +++ b/Classes/Core/PackageCollection.php @@ -82,7 +82,7 @@ public function getPackages(): array return $this->packages; } - public function sortPackages(DependencyOrderingService $dependencyOrderingService = null): void + public function sortPackages(?DependencyOrderingService $dependencyOrderingService = null): void { $sortedPackageKeys = $this->resolveSortedPackageKeys($dependencyOrderingService); usort( @@ -97,7 +97,7 @@ public function sortPackages(DependencyOrderingService $dependencyOrderingServic * @param array $packageStates * @return array */ - public function sortPackageStates(array $packageStates, DependencyOrderingService $dependencyOrderingService = null): array + public function sortPackageStates(array $packageStates, ?DependencyOrderingService $dependencyOrderingService = null): array { $sortedPackageKeys = $this->resolveSortedPackageKeys($dependencyOrderingService); uksort( @@ -117,7 +117,7 @@ public function sortPackageStates(array $packageStates, DependencyOrderingServic * * @return list */ - public function resolveSortedPackageKeys(DependencyOrderingService $dependencyOrderingService = null): array + public function resolveSortedPackageKeys(?DependencyOrderingService $dependencyOrderingService = null): array { $dependencyOrderingService ??= GeneralUtility::makeInstance(DependencyOrderingService::class); $allPackageConstraints = $this->resolveAllPackageConstraints(); From 3c758b0aab75f3cf56c3f418aa6d42335cf24e0b Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Sat, 10 Aug 2024 13:36:51 +0200 Subject: [PATCH 07/29] [TASK] Add PHP 8.4 to test matrix (#610) (#611) --- .github/workflows/ci.yml | 2 +- Build/Scripts/runTests.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67bdca40..0c3bc2cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1' , '8.2', '8.3' ] + php: [ '8.1' , '8.2', '8.3', '8.4'] steps: - name: Extract branch name diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 2ddb1502..0c7e789a 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -44,6 +44,7 @@ Options: - 8.1 (default): use PHP 8.1 - 8.2: use PHP 8.2 - 8.3: use PHP 8.3 + - 8.4: use PHP 8.4 -x Only with -s cgl|unit @@ -117,7 +118,7 @@ while getopts ":b:s:p:hxn" OPT; do ;; p) PHP_VERSION=${OPTARG} - if ! [[ ${PHP_VERSION} =~ ^(8.1|8.2|8.3)$ ]]; then + if ! [[ ${PHP_VERSION} =~ ^(8.1|8.2|8.3|8.4)$ ]]; then INVALID_OPTIONS+=("${OPTARG}") fi ;; From d317133015a7028854f05ad68b97666c7b6d634d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Wed, 14 Aug 2024 19:00:45 +0200 Subject: [PATCH 08/29] [TASK] Streamline `Testbase->setUpTestDatabase()` The `Testbase->setUpTestDatabase()` takes care of correct database setup for each test instance and that the database is created. To accomplish that, current TYPO3 connections are closed. Doctrine DriverManager is used as lowlevel tool to create the instance database. In case that the database has not been created, which occures on the first test execution per test-case, an exception is thrown and disturbes the `getDatabasePlatform <-> getServerVersion()` detection with the Doctrine construct. That has been mitigated by catching the exception within `Connection->getServerVersion()` and returning an empty string ending up retrieving at least a the lowest default platform for the driver. That was mainly a workaround in functional test runs. Doctrine DBAL 3.9.x & 4.1.x will introduce a new version based postgres platform class along with a new exception, if the server version does not match the version pattern - which is the case for an emtpy string and not returning a PostgresSQL platform anymore - failing to create the database. This change catches the exception for non existing database to prepare the removal of the connection workaround in `Connection->getServerVersion()`, but still ensures the ConnectionPool instances are closed. Minor code cleanups within the method are applied in the same run. --- Classes/Core/Testbase.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 9b04635d..e8885df9 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -668,10 +668,14 @@ public function setUpPackageStates( public function setUpTestDatabase(string $databaseName, string $originalDatabaseName): void { // First close existing connections from a possible previous test case and - // tell our ConnectionPool there are no current connections anymore. + // tell our ConnectionPool there are no current connections anymore. In case + // database does not exist yet, an exception is thrown which we catch here. $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); - $connection = $connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); - $connection->close(); + try { + $connection = $connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); + $connection->close(); + } catch (DBALException) { + } $connectionPool->resetConnections(); // Drop database if exists. Directly using the Doctrine DriverManager to @@ -689,8 +693,8 @@ public function setUpTestDatabase(string $databaseName, string $originalDatabase $configuration->setSchemaManagerFactory($coreSchemaFactory); } $driverConnection = DriverManager::getConnection($connectionParameters, $configuration); + $schemaManager = $driverConnection->createSchemaManager(); - $platform = $driverConnection->getDatabasePlatform(); $isSQLite = self::isSQLite($driverConnection); // doctrine/dbal no longer supports createDatabase() and dropDatabase() statements. Guard it. From b0745fde213d5ef771c7ba8820f81ca387fd7dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Sun, 29 Sep 2024 15:33:17 +0200 Subject: [PATCH 09/29] [TASK] Explicitly provide all `fgetcsv()` arguments With [1] the 5th parameter `$escape` of `fgetcsv()` must be provided either positional or using named arguments or a E_DEPRECATED will be emitted since `PHP 8.4.0 RC1` [2]. This change provide now all five parameter for `fgetcsv()` calls and thus using the positional approach to allow easier backporting to older TYPO3 version where named arguements are not usable. [1] https://github.com/php/php-src/pull/15569 [2] https://github.com/php/php-src/blob/ebee8df27ed/UPGRADING#L617-L622 Releases: main, 8, 7 --- Classes/Core/Functional/Framework/DataHandling/DataSet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Functional/Framework/DataHandling/DataSet.php b/Classes/Core/Functional/Framework/DataHandling/DataSet.php index 283db6ec..77f0c0b5 100644 --- a/Classes/Core/Functional/Framework/DataHandling/DataSet.php +++ b/Classes/Core/Functional/Framework/DataHandling/DataSet.php @@ -186,7 +186,7 @@ private static function readData(string $fileName): array // BOM not found - rewind pointer to start of file. rewind($fileHandle); } - while (!feof($fileHandle) && ($values = fgetcsv($fileHandle, 0)) !== false) { + while (!feof($fileHandle) && ($values = fgetcsv($fileHandle, 0, ',', '"', '\\')) !== false) { $rawData[] = $values; } fclose($fileHandle); From 480edc81d4b823c4f93a0b2dfc73457ae393f8ec Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Thu, 10 Oct 2024 16:25:19 +0200 Subject: [PATCH 10/29] [TASK] composer req composer/class-map-generator:^1.3.4 (#621) (#627) This has been merged to dev-main (to be TF 9) already. TF 8 needs this change to keep core v13 compat. TF needs this since core monorepo removed the copy of ClassMapGenerator with [1] for functional test legacy instances of extensions. > composer req composer/class-map-generator:^1.3.4 [1] https://review.typo3.org/c/Packages/TYPO3.CMS/+/86184 --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index c0f79ee6..95bfd940 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ }, "require": { "php": "^8.1", + "composer/class-map-generator": "^1.3.4", "guzzlehttp/psr7": "^2.5.0", "phpunit/phpunit": "^10.1 || ^11.0", "psr/container": "^1.1.0 || ^2.0.0", From 89313eaa33a44f8cf64098697ec09efc71c3f795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 16 Aug 2024 13:06:15 +0200 Subject: [PATCH 11/29] [TASK] Mitigiate deprecated database configuration This change writes functioal test instance database configuration using doctrine/dbal identifiers and structure directly. Thus, avoiding the need for the TYPO3 ConnectionPool to transform these values on the fly. Instead of default connection array $GLOBALS['TYPO3_CONF_VARS_']['DB']['Connections'] ['Default']['tableoptions'] = [] following array is written $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'] ['Default']['defaultTableOptions'] = [] for database driver `mysqli` and `pdo_mysql`, other platforms does not suport these settings. Additionally, `COLLATION` is used instead of the replaced `COLLATE` subkey to define the table collation within the default table options. Backport to typo3/testing-framework v8 will limit this to TYPO3 v13 and for v12 using the old values. [1] https://github.com/doctrine/dbal/pull/5246 [2] https://review.typo3.org/c/Packages/TYPO3.CMS/+/75211/4/typo3/sysext/core/Classes/Database/ConnectionPool.php#147 Releases: main, 8 --- .../Core/Functional/FunctionalTestCase.php | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index 45ca7044..dd0b89e2 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -310,6 +310,7 @@ protected function setUp(): void $testbase->linkFrameworkExtensionsToInstance($this->instancePath, $frameworkExtension); $testbase->linkPathsInTestInstance($this->instancePath, $this->pathsToLinkInTestInstance); $testbase->providePathsInTestInstance($this->instancePath, $this->pathsToProvideInTestInstance); + $localConfiguration = []; $localConfiguration['DB'] = $testbase->getOriginalDatabaseSettingsFromEnvironmentOrLocalConfiguration(); $originalDatabaseName = ''; @@ -332,15 +333,49 @@ protected function setUp(): void $localConfiguration['DB']['Connections']['Default']['dbname'] = $dbName; $testbase->testDatabaseNameIsNotTooLong($originalDatabaseName, $localConfiguration); if ($dbDriver === 'mysqli' || $dbDriver === 'pdo_mysql') { - $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf8mb4'; - $localConfiguration['DB']['Connections']['Default']['tableoptions']['charset'] = 'utf8mb4'; - $localConfiguration['DB']['Connections']['Default']['tableoptions']['collate'] = 'utf8mb4_unicode_ci'; - $localConfiguration['DB']['Connections']['Default']['initCommands'] = 'SET SESSION sql_mode = \'STRICT_ALL_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_VALUE_ON_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY\';'; + if ((new Typo3Version())->getMajorVersion() < 13) { + // This branch is removed in testing-framework ^9 + $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf8mb4'; + $localConfiguration['DB']['Connections']['Default']['tableoptions']['charset'] = 'utf8mb4'; + $localConfiguration['DB']['Connections']['Default']['tableoptions']['collate'] = 'utf8mb4_unicode_ci'; + } else { + // MySQL/MariaDB allows more specific settings, and default configuration specific to these + // platforms are handled here. That includes using the more specific `utf8mb4` charset like + // TYPO3 would determine and write during installation and also defining `defaultTableOptions` + // based on selected charset. + if (($localConfiguration['DB']['Connections']['Default']['charset'] ?? '') === '') { + $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf8mb4'; + } + if (($localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['charset'] ?? '') === '') { + $localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['charset'] = 'utf8mb4'; + } + if (($localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['collation'] ?? '') === '') { + $localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['collation'] + = $localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['charset'] . '_unicode_ci'; + } + } + $localConfiguration['DB']['Connections']['Default']['initCommands'] + = 'SET SESSION sql_mode = \'' . implode(',', [ + 'STRICT_ALL_TABLES', + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_AUTO_VALUE_ON_ZERO', + 'NO_ENGINE_SUBSTITUTION', + 'NO_ZERO_DATE', + 'NO_ZERO_IN_DATE', + 'ONLY_FULL_GROUP_BY', + ]) . '\';'; + } + // Postgres/SQLite requires to use `utf-8` as charset and does not support `utf8mb4`. + if (($localConfiguration['DB']['Connections']['Default']['charset'] ?? '') === '') { + $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf-8'; } } else { // sqlite dbs of all tests are stored in a dir parallel to instance roots. Allows defining this path as tmpfs. $testbase->createDirectory(dirname($this->instancePath) . '/functional-sqlite-dbs'); $localConfiguration['DB']['Connections']['Default']['path'] = $dbPathSqlite; + if (($localConfiguration['DB']['charset'] ?? '') === '') { + $localConfiguration['DB']['charset'] = 'utf-8'; + } } // Set some hard coded base settings for the instance. Those could be overruled by From cf5ab6a255409b1684dc134c54113ad109690f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Sun, 13 Oct 2024 11:32:56 +0200 Subject: [PATCH 12/29] [TASK] Use `utf8` as default charset for SQLite and PostgreSQL Literally, PostgresSQL and SQLite are smart enough to map `utf-8` correctly to their internal charset `utf8`, which means that the default would be okay. Recently added to the testing-framework, this has been handled before by TYPO3 core using `utf8`, it's changed to streamline towards the same writing throughout TYPO3. Releases: main, 8 --- Classes/Core/Functional/FunctionalTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index dd0b89e2..af5a1cd7 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -367,7 +367,7 @@ protected function setUp(): void } // Postgres/SQLite requires to use `utf-8` as charset and does not support `utf8mb4`. if (($localConfiguration['DB']['Connections']['Default']['charset'] ?? '') === '') { - $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf-8'; + $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf8'; } } else { // sqlite dbs of all tests are stored in a dir parallel to instance roots. Allows defining this path as tmpfs. From 2df1e91c23f7101f34b3fe37d5d479140b313864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Sun, 13 Oct 2024 11:39:34 +0200 Subject: [PATCH 13/29] [TASK] Use utf8 as default charset really for SQLite Literally, PostgresSQL and SQLite are smart enough to map `utf-8` correctly to their internal charset `utf8`, which means that the default would be okay. Recently added to the testing-framework, this has been handled before by TYPO3 core using `utf8`, it's changed to streamline towards the same writing throughout TYPO3. Releases: main, 8 --- Classes/Core/Functional/FunctionalTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index af5a1cd7..e2c78318 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -374,7 +374,7 @@ protected function setUp(): void $testbase->createDirectory(dirname($this->instancePath) . '/functional-sqlite-dbs'); $localConfiguration['DB']['Connections']['Default']['path'] = $dbPathSqlite; if (($localConfiguration['DB']['charset'] ?? '') === '') { - $localConfiguration['DB']['charset'] = 'utf-8'; + $localConfiguration['DB']['charset'] = 'utf8'; } } From 24197976e479526a4cda7e16000595644efb8c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Mon, 11 Nov 2024 20:56:32 +0100 Subject: [PATCH 14/29] [TASK] Use simplier and working checkout ref determination With the introduction of non-main branch scheduled workflow execution a adjusted checkout part in the `ci.yml` workflow file has been added to allow to define which branch should be checked out. That breaks pipeline execution for pull-requests opened from repository forks. This change replaces the old detection with a more simplified implementation, only setting the custom ref in case of github workflow_dispatch event execution using `''` as fallback which allows custom branch selection for workflow dispatching while keeping default repostiory and branch checkout intact. Releases: main, 8, 7 --- .github/workflows/ci.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3bc2cc..abd72fe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,15 +15,10 @@ jobs: php: [ '8.1' , '8.2', '8.3', '8.4'] steps: - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT - id: extract_branch - - - name: Checkout ${{ steps.extract_branch.outputs.branch }} + - name: Checkout ${{ github.event_name == 'workflow_dispatch' && github.head_ref || '' }} uses: actions/checkout@v4 with: - ref: ${{ steps.extract_branch.outputs.branch }} + ref: ${{ github.event_name == 'workflow_dispatch' && github.head_ref || '' }} - name: Composer install run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s composerUpdate From ee192358aa89ebaef493a5c4ea3a64270428b66f Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Tue, 26 Nov 2024 18:00:55 +0100 Subject: [PATCH 15/29] [TASK] Use friendsofphp/php-cs-fixer:^3.65.0 and fix cgl (#649) > composer req --dev friendsofphp/php-cs-fixer:^3.65.0 Releases: main, 8, 7 --- Resources/Core/Build/FunctionalTestsBootstrap.php | 1 + Resources/Core/Build/UnitTestsBootstrap.php | 1 + .../json_response/Configuration/RequestMiddlewares.php | 1 + composer.json | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Resources/Core/Build/FunctionalTestsBootstrap.php b/Resources/Core/Build/FunctionalTestsBootstrap.php index a95bc520..9882f8fe 100644 --- a/Resources/Core/Build/FunctionalTestsBootstrap.php +++ b/Resources/Core/Build/FunctionalTestsBootstrap.php @@ -1,4 +1,5 @@ Date: Tue, 26 Nov 2024 18:15:56 +0100 Subject: [PATCH 16/29] [TASK] phpstan v2 (#651) > composer req --dev phpstan/phpstan:^2.0.2 phpstan/phpstan-phpunit:^2.0.1 > Build/Scripts/runTests.sh -s phpstanGenerateBaseline -p 8.2 Releases: main, 8 --- Build/phpstan/phpstan-baseline.neon | 47 +++++++++++++++++++++++++++++ composer.json | 4 +-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Build/phpstan/phpstan-baseline.neon b/Build/phpstan/phpstan-baseline.neon index 364905f7..d7df627a 100644 --- a/Build/phpstan/phpstan-baseline.neon +++ b/Build/phpstan/phpstan-baseline.neon @@ -1,2 +1,49 @@ parameters: ignoreErrors: + - + message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: ../../Classes/Composer/ComposerPackageManager.php + + - + message: '#^Trait TYPO3\\TestingFramework\\Core\\AccessibleProxyTrait is used zero times and is not analysed\.$#' + identifier: trait.unused + count: 1 + path: ../../Classes/Core/AccessibleProxyTrait.php + + - + message: '#^Call to function method_exists\(\) with ''PHPUnit\\\\Metadata\\\\MetadataCollection'' and ''isWithoutErrorHandl…'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../Classes/Core/BaseTestCase.php + + - + message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../Classes/Core/Functional/Framework/Constraint/RequestSection/DoesNotHaveRecordConstraint.php + + - + message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../Classes/Core/Functional/Framework/Constraint/RequestSection/HasRecordConstraint.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: ../../Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseSnapshot.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 1 + path: ../../Classes/Core/Functional/FunctionalTestCase.php + + - + message: '#^Strict comparison using \!\=\= between int\|string\|true and false will always evaluate to true\.$#' + identifier: notIdentical.alwaysTrue + count: 1 + path: ../../Classes/Core/Functional/FunctionalTestCase.php diff --git a/composer.json b/composer.json index cfa963a9..e999f4e1 100644 --- a/composer.json +++ b/composer.json @@ -60,8 +60,8 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.65.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-phpunit": "^1.1.1", + "phpstan/phpstan": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.1", "typo3/cms-workspaces": "12.*.*@dev || 13.*.*@dev" }, "replace": { From fef0bd6e1bb6e33d9e2f16d05bf7cf8242ba7b9d Mon Sep 17 00:00:00 2001 From: Thomas Hohn Date: Tue, 29 Oct 2024 06:52:46 +0100 Subject: [PATCH 17/29] [TASK] Use non deprecated database connection options (tableoptions, collate) --- .../Core/Acceptance/Extension/BackendEnvironment.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Classes/Core/Acceptance/Extension/BackendEnvironment.php b/Classes/Core/Acceptance/Extension/BackendEnvironment.php index a073b645..35d7bdad 100644 --- a/Classes/Core/Acceptance/Extension/BackendEnvironment.php +++ b/Classes/Core/Acceptance/Extension/BackendEnvironment.php @@ -21,6 +21,7 @@ use Codeception\Events; use Codeception\Extension; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\DataSet; use TYPO3\TestingFramework\Core\Testbase; @@ -261,8 +262,15 @@ public function bootstrapTypo3Environment(SuiteEvent $suiteEvent) $testbase->testDatabaseNameIsNotTooLong($originalDatabaseName, $localConfiguration); if ($dbDriver === 'mysqli' || $dbDriver === 'pdo_mysql') { $localConfiguration['DB']['Connections']['Default']['charset'] = 'utf8mb4'; - $localConfiguration['DB']['Connections']['Default']['tableoptions']['charset'] = 'utf8mb4'; - $localConfiguration['DB']['Connections']['Default']['tableoptions']['collate'] = 'utf8mb4_unicode_ci'; + if ((new Typo3Version())->getMajorVersion() >= 12) { + // @todo Use this as default when TYPO3 v11 support is dropped. + $localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['charset'] = 'utf8mb4'; + $localConfiguration['DB']['Connections']['Default']['defaultTableOptions']['collation'] = 'utf8mb4_unicode_ci'; + } else { + // @todo Remove this when TYPO3 v11 support is dropped. + $localConfiguration['DB']['Connections']['Default']['tableoptions']['charset'] = 'utf8mb4'; + $localConfiguration['DB']['Connections']['Default']['tableoptions']['collate'] = 'utf8mb4_unicode_ci'; + } } } else { // sqlite dbs of all tests are stored in a dir parallel to instance roots. Allows defining this path as tmpfs. From 93ea4824480b2d76f33be73559ca0b915f84107e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Sat, 12 Oct 2024 11:28:57 +0200 Subject: [PATCH 18/29] [BUGFIX] Ensure correct path calculation on system build TYPO3 core `SystemEnvironmentBuilder` has been implement normal TYPO3 usages in mind respecting possible instance setup scenarios, to build low level system environment before taking configuration into account. Path calculations are based on different indicators, for example if TYPO3 is used in composer mode based on the PHP define() TYPO3_COMPOSER_MODE set during by the `typo3/cms-composer-installers` composer plugin during `composer install` actions. Per nature, PHP defines are immutable and cannot be changed anymore during runtime. As `typo3/testing-framework` is only installabe using composer the TYPO3_COMPOSER_MODE define is always set but is invalid for functional test instances build in legacy mode and making invalid path calculations. That was hidden quite some time now, and is related to a chain of changes over a very long period of time and finally popped up in early TYPO3 v13 development after introducing the configurable backend url feature. Functional tests building relative url for resources or links could run into the issue not retrieving the leading slash anymore due to path calculation errrors, leading to follup issues in `NormalizedParams` create method when using frontend requests. Eventually more developers run into that issue, but simply adjusted the test expectation instead analyzing the root of the curse and thus not reported it, which has been done recenently. To fix this issue changes on two fronts have to be made, in the TYPO3 core `SystemEnvironmentBuilder` [1] and in the testing-framework implementation. Note that only both changes together solves this issue, as the testing-framework change alone would not be executed due to using `self` for static method calls in the TYPO3 implementation. [1] https://review.typo3.org/c/Packages/TYPO3.CMS/+/86569 Resolves: #577 Releases: main, 8 --- Classes/Core/SystemEnvironmentBuilder.php | 21 ++++++++++++++++++--- Classes/Core/Testbase.php | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Classes/Core/SystemEnvironmentBuilder.php b/Classes/Core/SystemEnvironmentBuilder.php index d143b7e0..43f47f73 100644 --- a/Classes/Core/SystemEnvironmentBuilder.php +++ b/Classes/Core/SystemEnvironmentBuilder.php @@ -40,16 +40,19 @@ */ class SystemEnvironmentBuilder extends CoreSystemEnvironmentBuilder { + private static ?bool $composerMode = null; + /** * @todo: Change default $requestType to 0 when dropping support for TYPO3 v12 */ - public static function run(int $entryPointLevel = 0, int $requestType = CoreSystemEnvironmentBuilder::REQUESTTYPE_FE, bool $composerMode = false) + public static function run(int $entryPointLevel = 0, int $requestType = CoreSystemEnvironmentBuilder::REQUESTTYPE_FE, ?bool $composerMode = null): void { - CoreSystemEnvironmentBuilder::run($entryPointLevel, $requestType); + self::$composerMode = $composerMode; + parent::run($entryPointLevel, $requestType); Environment::initialize( Environment::getContext(), Environment::isCli(), - $composerMode, + static::usesComposerClassLoading(), Environment::getProjectPath(), Environment::getPublicPath(), Environment::getVarPath(), @@ -58,4 +61,16 @@ public static function run(int $entryPointLevel = 0, int $requestType = CoreSyst Environment::isWindows() ? 'WINDOWS' : 'UNIX' ); } + + /** + * Manage composer mode separated from TYPO3_COMPOSER_MODE define set by typo3/cms-composer-installers. + * + * Note that this will not with earlier TYPO3 versions than 13.4. + * @link https://review.typo3.org/c/Packages/TYPO3.CMS/+/86569 + * @link https://github.com/TYPO3/testing-framework/issues/577 + */ + protected static function usesComposerClassLoading(): bool + { + return self::$composerMode ?? parent::usesComposerClassLoading(); + } } diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index e8885df9..45244563 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -745,7 +745,7 @@ public function setUpBasicTypo3Bootstrap($instancePath): ContainerInterface $classLoader = require $this->getPackagesPath() . '/autoload.php'; // @todo: Remove else branch when dropping support for v12 if ($hasConsolidatedHttpEntryPoint) { - SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI); + SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, false); } else { SystemEnvironmentBuilder::run(1, SystemEnvironmentBuilder::REQUESTTYPE_BE | SystemEnvironmentBuilder::REQUESTTYPE_CLI); } From 5e49bc85c256c8ba4b2beffa1dc35faeab110769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Wed, 27 Nov 2024 17:59:12 +0100 Subject: [PATCH 19/29] [TASK] Add missing `composer.json` to testing-framework extensions The typo3/testing-framework provides two TYPO3 extensions, which are required and bound to every created functional test instance. Adding missing `composer.json` for these two extensions. Releases: main, 8 --- .../Extensions/json_response/composer.json | 42 +++++++++++++++++++ .../Extensions/json_response/ext_emconf.php | 4 +- .../private_container/composer.json | 42 +++++++++++++++++++ .../private_container/ext_emconf.php | 2 +- 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 Resources/Core/Functional/Extensions/json_response/composer.json create mode 100644 Resources/Core/Functional/Extensions/private_container/composer.json diff --git a/Resources/Core/Functional/Extensions/json_response/composer.json b/Resources/Core/Functional/Extensions/json_response/composer.json new file mode 100644 index 00000000..e0b15d7d --- /dev/null +++ b/Resources/Core/Functional/Extensions/json_response/composer.json @@ -0,0 +1,42 @@ +{ + "name": "typo3/testing-json-response", + "type": "typo3-cms-extension", + "description": "Providing testing framework extension for functional testing.", + "keywords": [ + "typo3", + "testing", + "tests" + ], + "homepage": "https://typo3.org/", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "TYPO3 CMS Core Team", + "role": "Developer", + "homepage": "https://forge.typo3.org/projects/typo3cms-core" + }, + { + "name": "The TYPO3 Community", + "role": "Contributor", + "homepage": "https://typo3.org/community/" + } + ], + "support": { + "general": "https://typo3.org/support/", + "issues": "https://github.com/TYPO3/testing-framework/issues" + }, + "require": { + "php": "^8.1", + "typo3/cms-core": "12.*.*@dev || 13.*.*@dev" + }, + "autoload": { + "psr-4": { + "TYPO3\\JsonResponse\\": "Classes/" + } + }, + "extra": { + "typo3/cms": { + "extension-key": "json_response" + } + } +} diff --git a/Resources/Core/Functional/Extensions/json_response/ext_emconf.php b/Resources/Core/Functional/Extensions/json_response/ext_emconf.php index 70dd550f..33519891 100644 --- a/Resources/Core/Functional/Extensions/json_response/ext_emconf.php +++ b/Resources/Core/Functional/Extensions/json_response/ext_emconf.php @@ -4,14 +4,14 @@ 'title' => 'JSON Response', 'description' => 'JSON Response', 'category' => 'example', - 'version' => '9.4.0', + 'version' => '1.0.0', 'state' => 'beta', 'author' => 'Oliver Hader', 'author_email' => 'oliver@typo3.org', 'author_company' => '', 'constraints' => [ 'depends' => [ - 'typo3' => '9.4.0', + 'typo3' => '12.0.0 - 13.9.99', ], 'conflicts' => [], 'suggests' => [], diff --git a/Resources/Core/Functional/Extensions/private_container/composer.json b/Resources/Core/Functional/Extensions/private_container/composer.json new file mode 100644 index 00000000..f8c30d36 --- /dev/null +++ b/Resources/Core/Functional/Extensions/private_container/composer.json @@ -0,0 +1,42 @@ +{ + "name": "typo3/testing-private-container", + "type": "typo3-cms-extension", + "description": "Providing testing framework extension for functional testing.", + "keywords": [ + "typo3", + "testing", + "tests" + ], + "homepage": "https://typo3.org/", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "TYPO3 CMS Core Team", + "role": "Developer", + "homepage": "https://forge.typo3.org/projects/typo3cms-core" + }, + { + "name": "The TYPO3 Community", + "role": "Contributor", + "homepage": "https://typo3.org/community/" + } + ], + "support": { + "general": "https://typo3.org/support/", + "issues": "https://github.com/TYPO3/testing-framework/issues" + }, + "require": { + "php": "^8.1", + "typo3/cms-core": "12.*.*@dev || 13.*.*@dev" + }, + "autoload": { + "psr-4": { + "TYPO3\\PrivateContainer\\": "Classes/" + } + }, + "extra": { + "typo3/cms": { + "extension-key": "private_container" + } + } +} diff --git a/Resources/Core/Functional/Extensions/private_container/ext_emconf.php b/Resources/Core/Functional/Extensions/private_container/ext_emconf.php index c760d150..074e77b3 100644 --- a/Resources/Core/Functional/Extensions/private_container/ext_emconf.php +++ b/Resources/Core/Functional/Extensions/private_container/ext_emconf.php @@ -10,7 +10,7 @@ 'author_company' => '', 'constraints' => [ 'depends' => [ - 'typo3' => '11.0.0-12.99.99', + 'typo3' => '12.0.0-13.99.99', ], 'conflicts' => [], 'suggests' => [], From f552cd85f3cf7f378c0b9947c2927ce4ec6e3046 Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Wed, 14 Feb 2024 13:15:33 +0100 Subject: [PATCH 20/29] [BUGFIX] Respect `composer mode only` extension in `FunctionalTestCase` The `typo3/testing-framework` creates functional test instances as "classic" mode instances, writing a `PackageStates.php` file composed using composer information, provided and handled by the internal `ComposerPackageManager` class. Extensions in the state file are not sorted based on their dependencies and suggestions. To mitigate this and having a correctly sorted extension state file, the `PackageCollection` service has been introduced with the goal to resort the extension state file after the initial write to provide the correct sorted extension state. The extension sorting within the state file is important, as the TYPO3 Core uses this sorting to loop over extensions to collect information from the extensions, for example TCA, TCAOverrides, ext_localconf.php and other things. Package sorting is here very important to allow extensions to reconfigure or change the stuff from other extensions, which is guaranteed in normal "composer" and "classic" mode instances. For "classic" mode instances, only the `ext_emconf.php` file is taken into account and "composer.json" as the source of thruth for "composer" mode instances since TYPO3 v12, which made the `ext_emconf.php` file obsolete for extensions only installed with composer. Many agencies removed the optional `ext_emconf.php` file for project local path extensions like a sitepackage to remove the maintenance burden, which is a valid case. Sadly, the `PackageCollection` adopted from the TYPO3 Core `PackageManager` did not reflected this case and failed for extensions to properly sort only on extension dependencies and keys due the mix of extension key and composer package name handling. Extension depending on another extension failed to be sorted correctly with following exception: UnexpectedValueException: The package "extension_key" depends on "composer/package-key" which is not present in the system. This change modifies the `PackageCollection` implementation to take only TYPO3 extension and system extension into account for dependency resolving and sorting, using `ext_emconf.php` depends/suggest information as first source and falling back to `composer` require and suggestion information. Resolves: #541 Releases: main, 8 --- Build/phpstan/phpstan.neon | 2 + Classes/Composer/ComposerPackageManager.php | 16 ++- Classes/Core/PackageCollection.php | 128 ++++++++++++++---- .../Composer/ComposerPackageManagerTest.php | 63 +++++++++ Tests/Unit/Core/PackageCollectionTest.php | 77 +++++++++++ .../Unit/Fixtures/Packages/PackageStates.php | 41 ++++++ .../Packages/PackageStates_sorted.php | 40 ++++++ .../Packages/package-identifier/composer.json | 17 +++ .../package-unsynced-extemconf/composer.json | 17 +++ .../package-unsynced-extemconf/ext_emconf.php | 20 +++ .../package-with-extemconf/composer.json | 18 +++ .../package-with-extemconf/ext_emconf.php | 20 +++ .../Fixtures/Packages/package0/composer.json | 17 +++ .../Fixtures/Packages/package1/composer.json | 21 +++ .../package2-unsynced-extemconf/composer.json | 18 +++ .../ext_emconf.php | 19 +++ .../Fixtures/Packages/package2/composer.json | 23 ++++ 17 files changed, 529 insertions(+), 28 deletions(-) create mode 100644 Tests/Unit/Core/PackageCollectionTest.php create mode 100644 Tests/Unit/Fixtures/Packages/PackageStates.php create mode 100644 Tests/Unit/Fixtures/Packages/PackageStates_sorted.php create mode 100644 Tests/Unit/Fixtures/Packages/package-identifier/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/ext_emconf.php create mode 100644 Tests/Unit/Fixtures/Packages/package-with-extemconf/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package-with-extemconf/ext_emconf.php create mode 100644 Tests/Unit/Fixtures/Packages/package0/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package1/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/composer.json create mode 100644 Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/ext_emconf.php create mode 100644 Tests/Unit/Fixtures/Packages/package2/composer.json diff --git a/Build/phpstan/phpstan.neon b/Build/phpstan/phpstan.neon index 56969c4d..2396ea0a 100644 --- a/Build/phpstan/phpstan.neon +++ b/Build/phpstan/phpstan.neon @@ -20,3 +20,5 @@ parameters: - ../../Classes/Core/Acceptance/* # Text fixtures extensions uses $_EXTKEY phpstan would be report as "might not defined" - ../../Tests/Unit/*/Fixtures/Extensions/*/ext_emconf.php + - ../../Tests/Unit/*/Fixtures/Packages/*/ext_emconf.php + - ../../Tests/Unit/Fixtures/Packages/*/ext_emconf.php diff --git a/Classes/Composer/ComposerPackageManager.php b/Classes/Composer/ComposerPackageManager.php index 467783b4..e3289dff 100644 --- a/Classes/Composer/ComposerPackageManager.php +++ b/Classes/Composer/ComposerPackageManager.php @@ -61,6 +61,7 @@ final class ComposerPackageManager public function __construct() { + // @todo Remove this from the constructor. $this->build(); } @@ -545,11 +546,20 @@ private function determineExtensionKey( ?array $info = null, ?array $extEmConf = null ): string { - $isExtension = in_array($info['type'] ?? '', ['typo3-cms-framework', 'typo3-cms-extension'], true) - || ($extEmConf !== null); - if (!$isExtension) { + $isComposerExtensionType = ($info !== null && array_key_exists('type', $info) && is_string($info['type']) && in_array($info['type'], ['typo3-cms-framework', 'typo3-cms-extension'], true)); + $hasExtEmConf = $extEmConf !== null; + if (!($isComposerExtensionType || $hasExtEmConf)) { return ''; } + $hasComposerExtensionKey = ( + is_array($info) + && isset($info['extra']['typo3/cms']['extension-key']) + && is_string($info['extra']['typo3/cms']['extension-key']) + && $info['extra']['typo3/cms']['extension-key'] !== '' + ); + if ($hasComposerExtensionKey) { + return $info['extra']['typo3/cms']['extension-key']; + } $baseName = basename($packagePath); if (($info['type'] ?? '') === 'typo3-csm-framework' && str_starts_with($baseName, 'cms-') diff --git a/Classes/Core/PackageCollection.php b/Classes/Core/PackageCollection.php index cbef267e..78c65aae 100644 --- a/Classes/Core/PackageCollection.php +++ b/Classes/Core/PackageCollection.php @@ -26,10 +26,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\PathUtility; use TYPO3\TestingFramework\Composer\ComposerPackageManager; +use TYPO3\TestingFramework\Composer\PackageInfo; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; /** - * Collection for extension packages to resolve their dependencies in a test-base. - * Most of the code has been duplicated and adjusted from `\TYPO3\CMS\Core\Package\PackageManager`. + * Composer package collection to resolve extension dependencies for classic-mode based test instances. + * + * This class resolves extension dependencies for composer packages to sort classic-mode PackageStates, + * which only takes TYPO3 extensions into account with a fallback to read composer information when the + * `ext_emconf.php` file is missing. + * + * Most of the code has been duplicated and adjusted from {@see PackageManager}. + * + * Background: + * =========== + * + * TYPO3 has the two installation mode "composer" and "classic". For the "composer" mode the package dependency handling + * is mainly done by composer and dependency detection and sorting is purely based on composer.json information. "Classic" + * mode uses only "ext_emconf.php" information to do the same job, not mixing it with the composer.json information when + * available. + * + * Since TYPO3 v12 extensions installed in "composer" mode are not required to provide a "ext_emconf.php" anymore, which + * makes them only installable within a "composer" mode installation. Agencies used to drop that file from local path + * extensions in "composer" mode projects, because it is a not needed requirement for them and avoids maintenance of it. + * + * typo3/testing-framework builds "classic" mode functional test instances while used within composer installations only, + * and introduced an extension sorting with this class to ensure to have a deterministic extension sorting like a real + * "classic" mode installation would provide in case extensions are not manually provided in the correct order within + * {@see FunctionalTestCase::$testExtensionToLoad} property. + * + * {@see PackageCollection} is based on the TYPO3 core {@see PackageManager} to provide a sorting for functional test + * instances, falling back to use composer.json information in case no "ext_emconf.php" are given limiting it only to + * TYPO3 compatible extensions (typo3-cms-framework and typo3-cms-extension composer package types). * * @phpstan-type PackageKey non-empty-string * @phpstan-type PackageName non-empty-string @@ -54,6 +82,10 @@ public static function fromPackageStates(ComposerPackageManager $composerPackage { $packages = []; foreach ($packageStates as $packageKey => $packageStateConfiguration) { + // @todo Verify retrieving package information and throwing early exception after extension without + // composer.json support has been dropped, even for simplified test fixture extensions. Think + // about triggering deprecation for this case first, which may also breaking from a testing + // perspective. $packagePath = PathUtility::sanitizeTrailingSeparator( rtrim($basePath, '/') . '/' . $packageStateConfiguration['packagePath'] ); @@ -162,8 +194,11 @@ protected function convertConfigurationForGraph(array $allPackageConstraints, ar ]; if (isset($allPackageConstraints[$packageKey]['dependencies'])) { foreach ($allPackageConstraints[$packageKey]['dependencies'] as $dependentPackageKey) { - if (!in_array($dependentPackageKey, $packageKeys, true)) { - if ($this->isComposerDependency($dependentPackageKey)) { + $extensionKey = $this->getPackageExtensionKey($dependentPackageKey); + if (!in_array($dependentPackageKey, $packageKeys, true) + && !in_array($extensionKey, $packageKeys, true) + ) { + if (!$this->isTypo3SystemOrCustomExtension($dependentPackageKey)) { // The given package has a dependency to a Composer package that has no relation to TYPO3 // We can ignore those, when calculating the extension order continue; @@ -174,21 +209,30 @@ protected function convertConfigurationForGraph(array $allPackageConstraints, ar 1519931815 ); } - $dependencies[$packageKey]['after'][] = $dependentPackageKey; + $dependencies[$packageKey]['after'][] = $extensionKey; } } if (isset($allPackageConstraints[$packageKey]['suggestions'])) { foreach ($allPackageConstraints[$packageKey]['suggestions'] as $suggestedPackageKey) { + $extensionKey = $this->getPackageExtensionKey($suggestedPackageKey); // skip suggestions on not existing packages - if (in_array($suggestedPackageKey, $packageKeys, true)) { - // Suggestions actually have never been meant to influence loading order. - // We misuse this currently, as there is no other way to influence the loading order - // for not-required packages (soft-dependency). - // When considering suggestions for the loading order, we might create a cyclic dependency - // if the suggested package already has a real dependency on this package, so the suggestion - // has do be dropped in this case and must *not* be taken into account for loading order evaluation. - $dependencies[$packageKey]['after-resilient'][] = $suggestedPackageKey; + if (!in_array($suggestedPackageKey, $packageKeys, true) + && !in_array($extensionKey, $packageKeys, true) + ) { + continue; + } + if (!$this->isTypo3SystemOrCustomExtension($extensionKey ?: $suggestedPackageKey)) { + // Ignore non TYPO3 extension packages for suggestion determination/ordering. + continue; } + + // Suggestions actually have never been meant to influence loading order. + // We misuse this currently, as there is no other way to influence the loading order + // for not-required packages (soft-dependency). + // When considering suggestions for the loading order, we might create a cyclic dependency + // if the suggested package already has a real dependency on this package, so the suggestion + // has do be dropped in this case and must *not* be taken into account for loading order evaluation. + $dependencies[$packageKey]['after-resilient'][] = $extensionKey; } } } @@ -255,25 +299,28 @@ protected function getDependencyArrayForPackage(PackageInterface $package, array foreach ($dependentPackageConstraints as $constraint) { if ($constraint instanceof PackageConstraint) { $dependentPackageKey = $constraint->getValue(); - if (!in_array($dependentPackageKey, $dependentPackageKeys, true) && !in_array($dependentPackageKey, $trace, true)) { - $dependentPackageKeys[] = $dependentPackageKey; + $extensionKey = $this->getPackageExtensionKey($dependentPackageKey) ?: $dependentPackageKey; + if (!in_array($extensionKey, $dependentPackageKeys, true)) { + $dependentPackageKeys[] = $extensionKey; } - if (!isset($this->packages[$dependentPackageKey])) { - if ($this->isComposerDependency($dependentPackageKey)) { + + if (!isset($this->packages[$extensionKey])) { + if (!$this->isTypo3SystemOrCustomExtension($extensionKey)) { // The given package has a dependency to a Composer package that has no relation to TYPO3 // We can ignore those, when calculating the extension order continue; } + throw new Exception( sprintf( 'Package "%s" depends on package "%s" which does not exist.', $package->getPackageKey(), - $dependentPackageKey + $extensionKey ), 1695119749 ); } - $this->getDependencyArrayForPackage($this->packages[$dependentPackageKey], $dependentPackageKeys, $trace); + $this->getDependencyArrayForPackage($this->packages[$extensionKey], $dependentPackageKeys, $trace); } } return array_reverse($dependentPackageKeys); @@ -292,9 +339,17 @@ protected function getSuggestionArrayForPackage(PackageInterface $package): arra foreach ($suggestedPackageConstraints as $constraint) { if ($constraint instanceof PackageConstraint) { $suggestedPackageKey = $constraint->getValue(); - if (isset($this->packages[$suggestedPackageKey])) { - $suggestedPackageKeys[] = $suggestedPackageKey; + $extensionKey = $this->getPackageExtensionKey($suggestedPackageKey) ?: $suggestedPackageKey; + if (!$this->isTypo3SystemOrCustomExtension($suggestedPackageKey)) { + // Suggested packages which are not installed or not a TYPO3 extension can be skipped for + // sorting when not available. + continue; } + if (!isset($this->packages[$extensionKey])) { + // Suggested extension is not available in test system installation (not symlinked), ignore it. + continue; + } + $suggestedPackageKeys[] = $extensionKey; } } return array_reverse($suggestedPackageKeys); @@ -308,15 +363,38 @@ protected function findFrameworkKeys(): array $frameworkKeys = []; foreach ($this->packages as $package) { if ($package->getPackageMetaData()->isFrameworkType()) { - $frameworkKeys[] = $package->getPackageKey(); + $frameworkKeys[] = $this->getPackageExtensionKey($package->getPackageKey()) ?: $package->getPackageKey(); } } return $frameworkKeys; } - protected function isComposerDependency(string $packageKey): bool + /** + * Determines if given composer package key is either a `typo3-cms-framework` or `typo3-cms-extension` package. + */ + protected function isTypo3SystemOrCustomExtension(string $packageKey): bool + { + $packageInfo = $this->getPackageInfo($packageKey); + if ($packageInfo === null) { + return false; + } + return $packageInfo->isSystemExtension() || $packageInfo->isExtension(); + } + + /** + * Returns package extension key. Returns empty string if not available. + */ + protected function getPackageExtensionKey(string $packageKey): string + { + $packageInfo = $this->getPackageInfo($packageKey); + if ($packageInfo === null) { + return ''; + } + return $packageInfo->getExtensionKey(); + } + + protected function getPackageInfo(string $packageKey): ?PackageInfo { - $packageInfo = $this->composerPackageManager->getPackageInfo($packageKey); - return !(($packageInfo?->isSystemExtension() ?? false) || ($packageInfo?->isExtension())); + return $this->composerPackageManager->getPackageInfo($packageKey); } } diff --git a/Tests/Unit/Composer/ComposerPackageManagerTest.php b/Tests/Unit/Composer/ComposerPackageManagerTest.php index bdf4e742..84d2a67a 100644 --- a/Tests/Unit/Composer/ComposerPackageManagerTest.php +++ b/Tests/Unit/Composer/ComposerPackageManagerTest.php @@ -295,4 +295,67 @@ public function extensionWithJsonCanBeResolvedByRelativeLegacyPath(): void self::assertNotNull($packageInfo->getInfo()); self::assertNotNull($packageInfo->getExtEmConf()); } + + public static function packagesWithoutExtEmConfFileDataProvider(): \Generator + { + yield 'package0 => package0' => [ + 'path' => __DIR__ . '/../Fixtures/Packages/package0', + 'expectedExtensionKey' => 'package0', + 'expectedPackageName' => 'typo3/testing-framework-package-0', + ]; + yield 'package0 => package1' => [ + 'path' => __DIR__ . '/../Fixtures/Packages/package1', + 'expectedExtensionKey' => 'package1', + 'expectedPackageName' => 'typo3/testing-framework-package-1', + ]; + yield 'package0 => package2' => [ + 'path' => __DIR__ . '/../Fixtures/Packages/package2', + 'expectedExtensionKey' => 'package2', + 'expectedPackageName' => 'typo3/testing-framework-package-2', + ]; + yield 'package-identifier => some_test_extension' => [ + 'path' => __DIR__ . '/../Fixtures/Packages/package-identifier', + 'expectedExtensionKey' => 'some_test_extension', + 'expectedPackageName' => 'typo3/testing-framework-package-identifier', + ]; + } + + #[DataProvider('packagesWithoutExtEmConfFileDataProvider')] + #[Test] + public function getPackageInfoWithFallbackReturnsExtensionInfoWithCorrectExtensionKeyWhenNotHavingAnExtEmConfFile( + string $path, + string $expectedExtensionKey, + string $expectedPackageName, + ): void { + $packageInfo = (new ComposerPackageManager())->getPackageInfoWithFallback($path); + self::assertInstanceOf(PackageInfo::class, $packageInfo, 'PackageInfo retrieved for ' . $path); + self::assertNull($packageInfo->getExtEmConf(), 'Package provides ext_emconf.php'); + self::assertNotNull($packageInfo->getInfo(), 'Package has no composer info (composer.json)'); + self::assertNotEmpty($packageInfo->getInfo(), 'Package composer info is empty'); + self::assertTrue($packageInfo->isExtension(), 'Package is not a extension'); + self::assertFalse($packageInfo->isSystemExtension(), 'Package is a system extension'); + self::assertTrue($packageInfo->isComposerPackage(), 'Package is not a composer package'); + self::assertFalse($packageInfo->isMonoRepository(), 'Package is mono repository'); + self::assertSame($expectedPackageName, $packageInfo->getName()); + self::assertSame($expectedExtensionKey, $packageInfo->getExtensionKey()); + } + + #[Test] + public function getPackageInfoWithFallbackReturnsExtensionInfoWithCorrectExtensionKeyAndHavingAnExtEmConfFile(): void + { + $path = __DIR__ . '/../Fixtures/Packages/package-with-extemconf'; + $expectedExtensionKey = 'extension_with_extemconf'; + $expectedPackageName = 'typo3/testing-framework-package-with-extemconf'; + $packageInfo = (new ComposerPackageManager())->getPackageInfoWithFallback($path); + self::assertInstanceOf(PackageInfo::class, $packageInfo, 'PackageInfo retrieved for ' . $path); + self::assertNotNull($packageInfo->getExtEmConf(), 'Package has ext_emconf.php file'); + self::assertNotNull($packageInfo->getInfo(), 'Package has composer info'); + self::assertNotEmpty($packageInfo->getInfo(), 'Package composer info is not empty'); + self::assertTrue($packageInfo->isExtension(), 'Package is a extension'); + self::assertFalse($packageInfo->isSystemExtension(), 'Package is not a system extension'); + self::assertTrue($packageInfo->isComposerPackage(), 'Package is a composer package'); + self::assertFalse($packageInfo->isMonoRepository(), 'Package is not mono repository root'); + self::assertSame($expectedPackageName, $packageInfo->getName()); + self::assertSame($expectedExtensionKey, $packageInfo->getExtensionKey()); + } } diff --git a/Tests/Unit/Core/PackageCollectionTest.php b/Tests/Unit/Core/PackageCollectionTest.php new file mode 100644 index 00000000..2a7bbfac --- /dev/null +++ b/Tests/Unit/Core/PackageCollectionTest.php @@ -0,0 +1,77 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Typo3\TestingFramework\Tests\Unit\Core; + +use PHPUnit\Framework\TestCase; +use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Service\DependencyOrderingService; +use TYPO3\TestingFramework\Composer\ComposerPackageManager; +use TYPO3\TestingFramework\Core\PackageCollection; + +final class PackageCollectionTest extends TestCase +{ + /** + * @test + */ + public function sortsComposerPackages(): void + { + $packageStates = require __DIR__ . '/../Fixtures/Packages/PackageStates.php'; + $expectedPackageStates = require __DIR__ . '/../Fixtures/Packages/PackageStates_sorted.php'; + $packageStates = $packageStates['packages']; + $basePath = realpath(__DIR__ . '/../../../'); + + $composerPackageManager = new ComposerPackageManager(); + // That way it knows about the extensions, this is done by TestBase upfront. + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package0'); + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package1'); + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package2'); + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package-with-extemconf'); + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package-unsynced-extemconf'); + $composerPackageManager->getPackageInfoWithFallback(__DIR__ . '/../Fixtures/Packages/package2-unsynced-extemconf'); + + $subject = PackageCollection::fromPackageStates( + $composerPackageManager, + new PackageManager( + new DependencyOrderingService(), + __DIR__ . '/../Fixtures/Packages/PackageStates.php', + $basePath + ), + $basePath, + $packageStates + ); + + $result = $subject->sortPackageStates( + $packageStates, + new DependencyOrderingService() + ); + + self::assertSame(5, array_search('package0', array_keys($result)), 'Package 0 is not stored at loading order 5.'); + self::assertSame(6, array_search('package1', array_keys($result)), 'Package 1 is not stored at loading order 6.'); + self::assertSame(7, array_search('extension_unsynced_extemconf', array_keys($result)), 'extension_unsynced_extemconf is not stored at loading order 7.'); + self::assertSame(8, array_search('extension_with_extemconf', array_keys($result)), 'extension_with_extemconf is not stored at loading order 8.'); + self::assertSame(9, array_search('extension2_unsynced_extemconf', array_keys($result)), 'extension2_unsynced_extemconf is not stored at loading order 9.'); + self::assertSame(10, array_search('package2', array_keys($result)), 'Package 2 is not stored at loading order 10.'); + self::assertSame($expectedPackageStates['packages'], $result, 'Sorted packages does not match expected order'); + } +} diff --git a/Tests/Unit/Fixtures/Packages/PackageStates.php b/Tests/Unit/Fixtures/Packages/PackageStates.php new file mode 100644 index 00000000..77ec64de --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/PackageStates.php @@ -0,0 +1,41 @@ + [ + 'package2' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package2/', + ], + 'extbase' => [ + 'packagePath' => '.Build/vendor/typo3/cms-extbase/', + ], + 'extension2_unsynced_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/', + ], + 'extension_unsynced_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/', + ], + 'extension_with_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package-with-extemconf/', + ], + 'package1' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package1/', + ], + 'fluid' => [ + 'packagePath' => '.Build/vendor/typo3/cms-fluid/', + ], + 'package0' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package0/', + ], + 'backend' => [ + 'packagePath' => '.Build/vendor/typo3/cms-backend/', + ], + 'frontend' => [ + 'packagePath' => '.Build/vendor/typo3/cms-frontend/', + ], + 'core' => [ + 'packagePath' => '.Build/vendor/typo3/cms-core/', + ], + ], + 'version' => 5, +]; diff --git a/Tests/Unit/Fixtures/Packages/PackageStates_sorted.php b/Tests/Unit/Fixtures/Packages/PackageStates_sorted.php new file mode 100644 index 00000000..8db674bb --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/PackageStates_sorted.php @@ -0,0 +1,40 @@ + [ + 'core' => [ + 'packagePath' => '.Build/vendor/typo3/cms-core/', + ], + 'extbase' => [ + 'packagePath' => '.Build/vendor/typo3/cms-extbase/', + ], + 'fluid' => [ + 'packagePath' => '.Build/vendor/typo3/cms-fluid/', + ], + 'backend' => [ + 'packagePath' => '.Build/vendor/typo3/cms-backend/', + ], + 'frontend' => [ + 'packagePath' => '.Build/vendor/typo3/cms-frontend/', + ], + 'package0' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package0/', + ], + 'package1' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package1/', + ], + 'extension_unsynced_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/', + ], + 'extension_with_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package-with-extemconf/', + ], + 'extension2_unsynced_extemconf' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/', + ], + 'package2' => [ + 'packagePath' => 'Tests/Unit/Fixtures/Packages/package2/', + ], + ], + 'version' => 5, +]; diff --git a/Tests/Unit/Fixtures/Packages/package-identifier/composer.json b/Tests/Unit/Fixtures/Packages/package-identifier/composer.json new file mode 100644 index 00000000..397b0898 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package-identifier/composer.json @@ -0,0 +1,17 @@ +{ + "name": "typo3/testing-framework-package-identifier", + "description": "Package 0", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "some_test_extension" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/composer.json b/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/composer.json new file mode 100644 index 00000000..5e0ad721 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/composer.json @@ -0,0 +1,17 @@ +{ + "name": "typo3/testing-framework-package-unsynced-extemconf", + "description": "Package 0", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "extension_unsynced_extemconf" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/ext_emconf.php b/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/ext_emconf.php new file mode 100644 index 00000000..5e62897b --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package-unsynced-extemconf/ext_emconf.php @@ -0,0 +1,20 @@ + 'typo3/testing-framework package test extension', + 'description' => '', + 'category' => 'be', + 'state' => 'stable', + 'author' => 'TYPO3 Core Team', + 'author_email' => 'typo3cms@typo3.org', + 'author_company' => '', + 'version' => '13.4.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '13.4.0', + 'package1' => '0.0.0-9.99.99', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/Tests/Unit/Fixtures/Packages/package-with-extemconf/composer.json b/Tests/Unit/Fixtures/Packages/package-with-extemconf/composer.json new file mode 100644 index 00000000..d1558d42 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package-with-extemconf/composer.json @@ -0,0 +1,18 @@ +{ + "name": "typo3/testing-framework-package-with-extemconf", + "description": "Package 0", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*", + "typo3/testing-framework-package-1": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "extension_with_extemconf" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package-with-extemconf/ext_emconf.php b/Tests/Unit/Fixtures/Packages/package-with-extemconf/ext_emconf.php new file mode 100644 index 00000000..5e62897b --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package-with-extemconf/ext_emconf.php @@ -0,0 +1,20 @@ + 'typo3/testing-framework package test extension', + 'description' => '', + 'category' => 'be', + 'state' => 'stable', + 'author' => 'TYPO3 Core Team', + 'author_email' => 'typo3cms@typo3.org', + 'author_company' => '', + 'version' => '13.4.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '13.4.0', + 'package1' => '0.0.0-9.99.99', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/Tests/Unit/Fixtures/Packages/package0/composer.json b/Tests/Unit/Fixtures/Packages/package0/composer.json new file mode 100644 index 00000000..96c0eab4 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package0/composer.json @@ -0,0 +1,17 @@ +{ + "name": "typo3/testing-framework-package-0", + "description": "Package 0", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "package0" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package1/composer.json b/Tests/Unit/Fixtures/Packages/package1/composer.json new file mode 100644 index 00000000..a4131ac8 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package1/composer.json @@ -0,0 +1,21 @@ +{ + "name": "typo3/testing-framework-package-1", + "description": "Package 1, with replace entry", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*", + "typo3/testing-framework-package-0": "*" + }, + "replace": { + "typo3-ter/package1": "self.version" + }, + "extra": { + "typo3/cms": { + "extension-key": "package1" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/composer.json b/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/composer.json new file mode 100644 index 00000000..befbcc8b --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/composer.json @@ -0,0 +1,18 @@ +{ + "name": "typo3/testing-framework-package2-unsynced-extemconf", + "description": "Package 0", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/cms-core": "*", + "typo3/testing-framework-package-1": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "extension2_unsynced_extemconf" + } + } +} diff --git a/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/ext_emconf.php b/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/ext_emconf.php new file mode 100644 index 00000000..4e84ca09 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package2-unsynced-extemconf/ext_emconf.php @@ -0,0 +1,19 @@ + 'typo3/testing-framework package test extension', + 'description' => '', + 'category' => 'be', + 'state' => 'stable', + 'author' => 'TYPO3 Core Team', + 'author_email' => 'typo3cms@typo3.org', + 'author_company' => '', + 'version' => '13.4.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '13.4.0', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/Tests/Unit/Fixtures/Packages/package2/composer.json b/Tests/Unit/Fixtures/Packages/package2/composer.json new file mode 100644 index 00000000..967c3c57 --- /dev/null +++ b/Tests/Unit/Fixtures/Packages/package2/composer.json @@ -0,0 +1,23 @@ +{ + "name": "typo3/testing-framework-package-2", + "description": "Package 2 depending on package 1", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*", + "typo3/testing-framework-package-1": "*", + "typo3/testing-framework-package-0": "*", + "typo3/testing-framework-package-with-extemconf": "*", + "typo3/testing-framework-package-with-extemconf": "*", + "typo3/testing-framework-package-unsynced-extemconf": "*", + "typo3/testing-framework-package2-unsynced-extemconf": "*", + "typo3/cms-core": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "package2" + } + } +} From fbe41d263a3f42d71c16c399e486cd021e77aebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Thu, 28 Nov 2024 11:17:30 +0100 Subject: [PATCH 21/29] [BUGFIX] Avoid resolving invalid TYPO3 extensions in `ComposerPackageManager` The `ComposerPackageManager` has been introduced to streamline the functional test instance creation process and provide all selected extensions (system, custom and test fixture) within the test instance, which original simply used relative path names for classic mode instances or a simple extension key: For `$coreExtensionsToLoad`: * typo3/sysext/backend * backend For `$testExtensionsToLoad`: * typo3conf/ext/my_ext_key * my_ext_key With `typo3/cms-composer-installers` version 4.0RC1 and 5 these paths could not be found anymore, because TYPO3 system extensions and extensions are no longer installed into the classic paths in a composer mode instance and left in the vendor folder, which is the case for usual root project or extension instance. Using the available composer information to determine the source for extensions unrelated to the real installation path, which can be configured with composer, was the way to mitigate this issue and `ComposerPackageManger` has been implemented to process these lookups while still supporting test fixture extensions not loaded by the root composer.json directly. The implementation tried to provide backwards compatible as much as possible along with fallback to use folder names as extension keys by simply using `basename()` in some code places and including not obvious side effects and lookup issues. `basename()` was also used on valid composer package names to resolve composer package name for that value as extension key for loaded packages, which leads to fetch the wrong composer package. The standlone fluid `typo3fluid/fluid` composer package name resolved and retrieved the TYPO3 system extension package `typo3/cms-fluid` using `getPackageInfo()`, which lead to issues in other places, for example the dependency ordering and resolving class `PackageCollection`. This change streamlines the `ComposerPackageManager` class to mitigate building and using invalid values to lookup extension composer package names and harden the registration process of extension even further. Guarding unit tests are added to cover this bugfix. Resolves: #553 Releases: main, 8 --- Classes/Composer/ComposerPackageManager.php | 91 ++++++- .../Core/Functional/FunctionalTestCase.php | 69 ++++- .../Composer/ComposerPackageManagerTest.php | 257 ++++++++++++++++++ .../composer.json | 18 ++ .../Packages/sharedextensionkey/composer.json | 12 + 5 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 Tests/Unit/Composer/Fixtures/Extensions/extension-key-shared-with-composer-package/composer.json create mode 100644 Tests/Unit/Composer/Fixtures/Packages/sharedextensionkey/composer.json diff --git a/Classes/Composer/ComposerPackageManager.php b/Classes/Composer/ComposerPackageManager.php index e3289dff..5df35614 100644 --- a/Classes/Composer/ComposerPackageManager.php +++ b/Classes/Composer/ComposerPackageManager.php @@ -18,8 +18,22 @@ */ use Composer\InstalledVersions; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; /** + * `typo3/testing-framework` internal composer package manager, used to gather source + * information of extensions already loaded by the root composer installation with + * the additional ability to register test fixture packages and extensions during + * runtime to create {@see FunctionalTestCase} test instances and provide symlinks + * of extensions into the classic mode test instance or retrieve files from a composer + * package or extension unrelated where they are placed on the filesystem. + * + * - {@see Testbase::setUpInstanceCoreLinks()} + * - {@see Testbase::linkTestExtensionsToInstance()} + * - {@see Testbase::linkFrameworkExtensionsToInstance()} + * - {@see Testbase::setUpLocalConfiguration()} + * - {@see Testbase::setUpPackageStates()} + * * @internal This class is for testing-framework internal processing and not part of public testing API. */ final class ComposerPackageManager @@ -65,25 +79,29 @@ public function __construct() $this->build(); } - public function getPackageInfoWithFallback(string $name): ?PackageInfo + /** + * Get composer package information {@see PackageInfo} for `$nameOrExtensionKeyOrPath`. + */ + public function getPackageInfoWithFallback(string $nameOrExtensionKeyOrPath): ?PackageInfo { - if ($packageInfo = $this->getPackageInfo($name)) { + if ($packageInfo = $this->getPackageInfo($nameOrExtensionKeyOrPath)) { return $packageInfo; } - if ($packageInfo = $this->getPackageFromPath($name)) { + if ($packageInfo = $this->getPackageFromPath($nameOrExtensionKeyOrPath)) { return $packageInfo; } - if ($packageInfo = $this->getPackageFromPathFallback($name)) { + if ($packageInfo = $this->getPackageFromPathFallback($nameOrExtensionKeyOrPath)) { return $packageInfo; } - return null; } + /** + * Get {@see PackageInfo} for package name or extension key `$name`. + */ public function getPackageInfo(string $name): ?PackageInfo { - $name = $this->resolvePackageName($name); - return self::$packages[$name] ?? null; + return self::$packages[$this->resolvePackageName($name)] ?? null; } /** @@ -403,9 +421,23 @@ private function getExtEmConf(string $path): ?array return null; } + /** + * Returns resolved composer package name when $name is a known extension key + * for a known package, otherwise return $name unchanged. + * + * Used to determine the package name to look up as composer package within {@see self::$packages} + * + * Supports also relative classic mode notation: + * + * - typo3/sysext/backend + * - typo3conf/ext/my_ext_key + * + * {@see self::prepareResolvePackageName()} for details for normalisation. + */ private function resolvePackageName(string $name): string { - return self::$extensionKeyToPackageNameMap[$this->normalizeExtensionKey(basename($name))] ?? $name; + $name = $this->prepareResolvePackageName($name); + return self::$extensionKeyToPackageNameMap[$name] ?? $name; } /** @@ -640,4 +672,47 @@ private function getFirstPathElement(string $path): string } return explode('/', $path)[0] ?? ''; } + + /** + * Extension can be specified with their composer name, extension key or with classic mode relative path + * prefixes (`typo3/sysext/` or `typo3conf/ext/`) for functional tests to + * configure which extension should be provided in the test instance. + * + * This method normalizes a handed over name by removing the specified extra information, so it can be + * used to resolve it either as direct package name or as extension name. + * + * Handed over value also removes known environment prefix paths, like the full path to the root (project rook), + * vendor folder or web folder using {@see self::removePrefixPaths()} which is safe, as this method is and most + * only be used for {@see self::resolvePackageName()} to find a composer package in {@see self::$packages}, after + * mapping extension-key to composer package name. + * + * Example for processed changes: + * -----------------------------_ + * + * - typo3/sysext/backend => backend + * - typo3conf/ext/my_ext_key => my_ext_key + * + * Example not processed values: + * ----------------------------- + * + * valid names + * - typo3/cms-core => typo3/cms-core + * - my-vendor/my-package-name => my-vendor/my-package-name + * - my-package-name-without-vendor => my-package-name-without-vendor + */ + private function prepareResolvePackageName($name): string + { + $name = trim($this->removePrefixPaths($name), '/'); + $relativePrefixPaths = [ + 'typo3/sysext/', + 'typo3conf/ext/', + ]; + foreach ($relativePrefixPaths as $relativePrefixPath) { + if (!str_starts_with($name, $relativePrefixPath)) { + continue; + } + $name = substr($name, mb_strlen($relativePrefixPath)); + } + return $name; + } } diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index e2c78318..15d45371 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -111,6 +111,23 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter * * A default list of core extensions is always loaded. * + * System extension can be provided by their extension key or composer package name, + * and also as classic mode relative path + * + * ``` + * protected array $coreExensionToLoad = [ + * // As composer package name + * 'typo3/cms-core', + * // As extension-key + * 'core', + * // As relative classic mode system installation path + * 'typo3/sysext/core', + * ]; + * ``` + * + * Note that system extensions must be available, which means either added as require or + * require-dev to the root composer.json or required and installed by a required package. + * * @see FunctionalTestCaseUtility $defaultActivatedCoreExtensions * * @var non-empty-string[] @@ -121,16 +138,32 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter * Array of test/fixture extensions paths that should be loaded for a test. * * This property will stay empty in this abstract, so it is possible - * to just overwrite it in extending classes. Extensions noted here will - * be loaded for every test of a test case, and it is not possible to change - * the list of loaded extensions between single tests of a test case. + * to just overwrite it in extending classes. + * + * IMPORTANT: Extension list is concrete and used to create the test instance on first + * test execution and is **NOT** changeable between single test permutations. * * Given path is expected to be relative to your document root, example: * - * array( - * 'typo3conf/ext/some_extension/Tests/Functional/Fixtures/Extensions/test_extension', + * ``` + * protected array $testExtensionToLoad = [ + * + * // Virtual relative classic mode installation path * 'typo3conf/ext/base_extension', - * ); + * + * // Virtual relative classic mode installation path subfolder test fixture + * 'typo3conf/ext/some_extension/Tests/Functional/Fixtures/Extensions/test_extension', + * + * // Relative to current test case (recommended for test fixture extension) + * __DIR__ . '/../Fixtures/Extensions/another_test_extension', + * + * // composer package name when available as `require` or `require-dev` in root composer.json + * 'vendor/some-extension', + * + * // extension key when available as package loaded as `require` or `require-dev` in root composer.json + * 'my_extension_key', + * ]; + * ``` * * Extensions in this array are linked to the test instance, loaded * and their ext_tables.sql will be applied. @@ -147,18 +180,22 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter * be linked for every test of a test case, and it is not possible to change * the list of folders between single tests of a test case. * - * array( + * ``` + * protected array $pathsToLinkInTestInstance = [ * 'link-source' => 'link-destination' - * ); + * ]; + * ``` * * Given paths are expected to be relative to the test instance root. * The array keys are the source paths and the array values are the destination * paths, example: * - * [ + * ``` + * protected array $pathsToLinkInTestInstance = [ * 'typo3/sysext/impext/Tests/Functional/Fixtures/Folders/fileadmin/user_upload' => * 'fileadmin/user_upload', - * ] + * ]; + * ``` * * To be able to link from my_own_ext the extension path needs also to be registered in * property $testExtensionsToLoad @@ -172,12 +209,14 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter * paths are really duplicated and provided in the instance - instead of * using symbolic links. Examples: * - * [ + * ``` + * protected array $pathsToProvideInTestInstance = [ * // Copy an entire directory recursive to fileadmin * 'typo3/sysext/lowlevel/Tests/Functional/Fixtures/testImages/' => 'fileadmin/', * // Copy a single file into some deep destination directory * 'typo3/sysext/lowlevel/Tests/Functional/Fixtures/testImage/someImage.jpg' => 'fileadmin/_processed_/0/a/someImage.jpg', - * ] + * ]; + * ``` * * @var array */ @@ -208,9 +247,11 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter * To create additional folders add the paths to this array. Given paths are expected to be * relative to the test instance root and have to begin with a slash. Example: * - * [ + * ``` + * protected array $additionalFoldersToCreate = [ * 'fileadmin/user_upload' - * ] + * ]; + * ``` * * @var non-empty-string[] */ diff --git a/Tests/Unit/Composer/ComposerPackageManagerTest.php b/Tests/Unit/Composer/ComposerPackageManagerTest.php index 84d2a67a..8db5463e 100644 --- a/Tests/Unit/Composer/ComposerPackageManagerTest.php +++ b/Tests/Unit/Composer/ComposerPackageManagerTest.php @@ -201,8 +201,15 @@ public function coreExtensionCanBeResolvedWithRelativeLegacyPathPrefix(): void public function extensionWithoutJsonCanBeResolvedByAbsolutePath(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback(__DIR__ . '/Fixtures/Extensions/ext_without_composerjson_absolute'); + // Extension without composer.json registers basefolder as extension key + self::assertArrayHasKey('ext_without_composerjson_absolute', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('unknown-vendor/ext-without-composerjson-absolute', $extensionMapPropertyReflection->getValue($subject)['ext_without_composerjson_absolute']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('ext_without_composerjson_absolute', $packageInfo->getExtensionKey()); self::assertSame('unknown-vendor/ext-without-composerjson-absolute', $packageInfo->getName()); @@ -215,8 +222,15 @@ public function extensionWithoutJsonCanBeResolvedByAbsolutePath(): void public function extensionWithoutJsonCanBeResolvedRelativeFromRoot(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback('Tests/Unit/Composer/Fixtures/Extensions/ext_without_composerjson_relativefromroot'); + // Extension without composer.json registers basefolder as extension key + self::assertArrayHasKey('ext_without_composerjson_relativefromroot', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('unknown-vendor/ext-without-composerjson-relativefromroot', $extensionMapPropertyReflection->getValue($subject)['ext_without_composerjson_relativefromroot']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('ext_without_composerjson_relativefromroot', $packageInfo->getExtensionKey()); self::assertSame('unknown-vendor/ext-without-composerjson-relativefromroot', $packageInfo->getName()); @@ -229,22 +243,40 @@ public function extensionWithoutJsonCanBeResolvedRelativeFromRoot(): void public function extensionWithoutJsonCanBeResolvedByLegacyPath(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback('typo3conf/ext/testing_framework/Tests/Unit/Composer/Fixtures/Extensions/ext_without_composerjson_fallbackroot'); + // Extension without composer.json registers basefolder as extension key + self::assertArrayHasKey('ext_without_composerjson_fallbackroot', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('unknown-vendor/ext-without-composerjson-fallbackroot', $extensionMapPropertyReflection->getValue($subject)['ext_without_composerjson_fallbackroot']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('ext_without_composerjson_fallbackroot', $packageInfo->getExtensionKey()); self::assertSame('unknown-vendor/ext-without-composerjson-fallbackroot', $packageInfo->getName()); self::assertSame('typo3-cms-extension', $packageInfo->getType()); self::assertNull($packageInfo->getInfo()); self::assertNotNull($packageInfo->getExtEmConf()); + } #[Test] public function extensionWithJsonCanBeResolvedByAbsolutePath(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback(__DIR__ . '/Fixtures/Extensions/ext_absolute'); + // Extension with composer.json and extension key does not register basepath as extension key + self::assertArrayNotHasKey('ext_absolute', $extensionMapPropertyReflection->getValue($subject)); + + // Extension with composer.json and extension key register extension key as composer package alias + self::assertArrayHasKey('absolute_real', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('testing-framework/extension-absolute', $extensionMapPropertyReflection->getValue($subject)['absolute_real']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('absolute_real', $packageInfo->getExtensionKey()); self::assertSame('testing-framework/extension-absolute', $packageInfo->getName()); @@ -257,8 +289,18 @@ public function extensionWithJsonCanBeResolvedByAbsolutePath(): void public function extensionWithJsonCanBeResolvedRelativeFromRoot(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback('Tests/Unit/Composer/Fixtures/Extensions/ext_relativefromroot'); + // Extension with composer.json and extension key does not register basepath as extension key + self::assertArrayNotHasKey('ext_relativefromroot', $extensionMapPropertyReflection->getValue($subject)); + + // Extension with composer.json and extension key register extension key as composer package alias + self::assertArrayHasKey('relativefromroot_real', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('testing-framework/extension-relativefromroot', $extensionMapPropertyReflection->getValue($subject)['relativefromroot_real']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('relativefromroot_real', $packageInfo->getExtensionKey()); self::assertSame('testing-framework/extension-relativefromroot', $packageInfo->getName()); @@ -271,8 +313,18 @@ public function extensionWithJsonCanBeResolvedRelativeFromRoot(): void public function extensionWithJsonCanBeResolvedByLegacyPath(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $packageInfo = $subject->getPackageInfoWithFallback('typo3conf/ext/testing_framework/Tests/Unit/Composer/Fixtures/Extensions/ext_fallbackroot'); + // Extension with composer.json and extension key does not register basepath as extension key + self::assertArrayNotHasKey('ext_fallbackroot', $extensionMapPropertyReflection->getValue($subject)); + + // Extension with composer.json and extension key register extension key as composer package alias + self::assertArrayHasKey('fallbackroot_real', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('testing-framework/extension-fallbackroot', $extensionMapPropertyReflection->getValue($subject)['fallbackroot_real']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('fallbackroot_real', $packageInfo->getExtensionKey()); self::assertSame('testing-framework/extension-fallbackroot', $packageInfo->getName()); @@ -285,9 +337,19 @@ public function extensionWithJsonCanBeResolvedByLegacyPath(): void public function extensionWithJsonCanBeResolvedByRelativeLegacyPath(): void { $subject = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($subject, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($subject)); $projectFolderName = basename($subject->getRootPath()); $packageInfo = $subject->getPackageInfoWithFallback('../' . $projectFolderName . '/typo3conf/ext/testing_framework/Tests/Unit/Composer/Fixtures/Extensions/ext_fallbackroot'); + // Extension with composer.json and extension key does not register basepath as extension key + self::assertArrayNotHasKey('ext_fallbackroot', $extensionMapPropertyReflection->getValue($subject)); + + // Extension with composer.json and extension key register extension key as composer package alias + self::assertArrayHasKey('fallbackroot_real', $extensionMapPropertyReflection->getValue($subject)); + self::assertSame('testing-framework/extension-fallbackroot', $extensionMapPropertyReflection->getValue($subject)['fallbackroot_real']); + + // Verify package info self::assertInstanceOf(PackageInfo::class, $packageInfo); self::assertSame('fallbackroot_real', $packageInfo->getExtensionKey()); self::assertSame('testing-framework/extension-fallbackroot', $packageInfo->getName()); @@ -358,4 +420,199 @@ public function getPackageInfoWithFallbackReturnsExtensionInfoWithCorrectExtensi self::assertSame($expectedPackageName, $packageInfo->getName()); self::assertSame($expectedExtensionKey, $packageInfo->getExtensionKey()); } + + public static function prepareResolvePackageNameReturnsExpectedValuesDataProvider(): \Generator + { + yield 'Composer package name returns unchanged (not checked for existence)' => [ + 'name' => 'typo3/cms-core', + 'expected' => 'typo3/cms-core', + ]; + yield 'Extension key returns unchanged (not checked for existence)' => [ + 'name' => 'core', + 'expected' => 'core', + ]; + yield 'Classic mode system path returns extension key (not checked for existence)' => [ + 'name' => 'typo3/sysext/core', + 'expected' => 'core', + ]; + yield 'Classic mode extension path returns extension key (not checked for existence)' => [ + 'name' => 'typo3conf/ext/some_ext', + 'expected' => 'some_ext', + ]; + yield 'Not existing full path to classic system extension path resolves to extension key (not checked for existence)' => [ + 'name' => 'ROOT:/typo3/sysext/core', + 'expected' => 'core', + ]; + yield 'Not existing full path to classic extension path resolves to extension key (not checked for existence)' => [ + 'name' => 'ROOT:/typo3conf/ext/some_ext', + 'expected' => 'some_ext', + ]; + yield 'Vendor path returns vendor with package subfolder' => [ + 'name' => 'VENDOR:/typo3/cms-core', + 'expected' => 'typo3/cms-core', + ]; + } + + #[DataProvider('prepareResolvePackageNameReturnsExpectedValuesDataProvider')] + #[Test] + public function prepareResolvePackageNameReturnsExpectedValues(string $name, string $expected): void + { + $composerPackageManager = new ComposerPackageManager(); + $replaceMap = [ + 'ROOT:/' => rtrim($composerPackageManager->getRootPath(), '/') . '/', + 'VENDOR:/' => rtrim($composerPackageManager->getVendorPath(), '/') . '/', + ]; + $name = str_replace(array_keys($replaceMap), array_values($replaceMap), $name); + foreach (array_keys($replaceMap) as $replaceKey) { + self::assertStringNotContainsString($replaceKey, $name, 'Key "%s" is replaced in name "%s"'); + } + $prepareResolvePackageNameReflectionMethod = new \ReflectionMethod($composerPackageManager, 'prepareResolvePackageName'); + $resolved = $prepareResolvePackageNameReflectionMethod->invoke($composerPackageManager, $name); + self::assertSame($expected, $resolved, sprintf('"%s" resolved to "%s"', $name, $expected)); + } + + public static function resolvePackageNameReturnsExpectedPackageNameDataProvider(): \Generator + { + yield 'Composer package name returns unchanged (not checked for existence)' => [ + 'name' => 'typo3/cms-core', + 'expected' => 'typo3/cms-core', + ]; + yield 'Extension key returns unchanged (not checked for existence)' => [ + 'name' => 'core', + 'expected' => 'typo3/cms-core', + ]; + yield 'Classic mode system path returns extension key (not checked for existence)' => [ + 'name' => 'typo3/sysext/core', + 'expected' => 'typo3/cms-core', + ]; + yield 'Not existing full path to classic system extension path resolves to extension key (not checked for existence)' => [ + 'name' => 'ROOT:/typo3/sysext/core', + 'expected' => 'typo3/cms-core', + ]; + yield 'Vendor path returns vendor with package subfolder' => [ + 'name' => 'VENDOR:/typo3/cms-core', + 'expected' => 'typo3/cms-core', + ]; + // Not loaded/known extension resolves only extension key and not to a composer package name. + yield 'Not existing full path to classic extension path resolves to extension key for unknown extension' => [ + 'name' => 'ROOT:/typo3conf/ext/some_ext', + 'expected' => 'some_ext', + ]; + // Not loaded/known extension resolves only extension key and not to a composer package name. + yield 'Classic mode extension path returns extension key for unknown extension' => [ + 'name' => 'typo3conf/ext/some_ext', + 'expected' => 'some_ext', + ]; + } + + #[DataProvider('resolvePackageNameReturnsExpectedPackageNameDataProvider')] + #[Test] + public function resolvePackageNameReturnsExpectedPackageName(string $name, string $expected): void + { + $composerPackageManager = new ComposerPackageManager(); + $replaceMap = [ + 'ROOT:/' => rtrim($composerPackageManager->getRootPath(), '/') . '/', + 'VENDOR:/' => rtrim($composerPackageManager->getVendorPath(), '/') . '/', + ]; + $name = str_replace(array_keys($replaceMap), array_values($replaceMap), $name); + foreach (array_keys($replaceMap) as $replaceKey) { + self::assertStringNotContainsString($replaceKey, $name, 'Key "%s" is replaced in name "%s"'); + } + $resolvePackageNameReflectionMethod = new \ReflectionMethod($composerPackageManager, 'resolvePackageName'); + $resolved = $resolvePackageNameReflectionMethod->invoke($composerPackageManager, $name); + self::assertSame($expected, $resolved, sprintf('"%s" resolved to "%s"', $name, $expected)); + } + + #[Test] + public function ensureEndingComposerPackageNameAndTypoExtensionPackageExtensionKeyResolvesCorrectPackage(): void + { + $composerManager = new ComposerPackageManager(); + $extensionMapPropertyReflection = new \ReflectionProperty($composerManager, 'extensionKeyToPackageNameMap'); + self::assertIsArray($extensionMapPropertyReflection->getValue($composerManager)); + + // verify initial composer package information + $initComposerPackage = $composerManager->getPackageInfoWithFallback(__DIR__ . '/Fixtures/Packages/sharedextensionkey'); + self::assertArrayNotHasKey('sharedextensionkey', $extensionMapPropertyReflection->getValue($composerManager)); + self::assertInstanceOf(PackageInfo::class, $initComposerPackage); + self::assertSame('testing-framework/sharedextensionkey', $initComposerPackage->getName(), 'PackageInfo->name is "testing-framework/sharedextensionkey"'); + self::assertFalse($initComposerPackage->isSystemExtension(), '"testing-framework/sharedextensionkey" is not a TYPO3 system extension'); + self::assertFalse($initComposerPackage->isExtension(), '"testing-framework/sharedextensionkey" is not a TYPO3 extension'); + self::assertTrue($initComposerPackage->isComposerPackage(), '"testing-framework/sharedextensionkey" is a composer package'); + self::assertSame('', $initComposerPackage->getExtensionKey()); + + // verify initial extension package information + $initExtensionPackage = $composerManager->getPackageInfoWithFallback(__DIR__ . '/Fixtures/Extensions/extension-key-shared-with-composer-package'); + self::assertArrayHasKey('sharedextensionkey', $extensionMapPropertyReflection->getValue($composerManager)); + self::assertSame('testing-framework/extension-key-shared-with-composer-package', $extensionMapPropertyReflection->getValue($composerManager)['sharedextensionkey']); + self::assertInstanceOf(PackageInfo::class, $initExtensionPackage); + self::assertSame('testing-framework/extension-key-shared-with-composer-package', $initExtensionPackage->getName(), 'PackageInfo->name is "testing-framework/extension-key-shared-with-composer-package"'); + self::assertFalse($initExtensionPackage->isSystemExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 system extension'); + self::assertTrue($initExtensionPackage->isExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 extension'); + self::assertTrue($initExtensionPackage->isComposerPackage(), '"testing-framework/extension-key-shared-with-composer-package" is a composer package'); + self::assertSame('sharedextensionkey', $initExtensionPackage->getExtensionKey()); + + // verify shared extension key retrieval returns the extension package + $extensionPackage = $composerManager->getPackageInfo('sharedextensionkey'); + self::assertInstanceOf(PackageInfo::class, $extensionPackage); + self::assertSame('testing-framework/extension-key-shared-with-composer-package', $extensionPackage->getName(), 'PackageInfo->name is "testing-framework/extension-key-shared-with-composer-package"'); + self::assertFalse($extensionPackage->isSystemExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 system extension'); + self::assertTrue($extensionPackage->isExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 extension'); + self::assertTrue($extensionPackage->isComposerPackage(), '"testing-framework/extension-key-shared-with-composer-package" is a composer package'); + self::assertSame('sharedextensionkey', $extensionPackage->getExtensionKey()); + + // verify shared extension key with classic mode prefix retrieval returns the extension package + $classicModeExtensionPackage = $composerManager->getPackageInfo('typo3conf/ext/sharedextensionkey'); + self::assertInstanceOf(PackageInfo::class, $classicModeExtensionPackage); + self::assertSame('testing-framework/extension-key-shared-with-composer-package', $classicModeExtensionPackage->getName(), 'PackageInfo->name is "testing-framework/extension-key-shared-with-composer-package"'); + self::assertFalse($classicModeExtensionPackage->isSystemExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 system extension'); + self::assertTrue($classicModeExtensionPackage->isExtension(), '"testing-framework/extension-key-shared-with-composer-package" is not a TYPO3 extension'); + self::assertTrue($classicModeExtensionPackage->isComposerPackage(), '"testing-framework/extension-key-shared-with-composer-package" is a composer package'); + self::assertSame('sharedextensionkey', $classicModeExtensionPackage->getExtensionKey()); + } + + /** + * @todo Remove this when fluid/standalone fluid is no longer available by default due to core dependencies. + * {@see ensureEndingComposerPackageNameAndTypoExtensionPackageExtensionKeyResolvesCorrectPackage} + */ + #[Test] + public function ensureStandaloneFluidDoesNotBreakCoreFluidExtension(): void + { + $composerManager = new ComposerPackageManager(); + + // Verify standalone fluid composer package + $standaloneFluid = $composerManager->getPackageInfo('typo3fluid/fluid'); + self::assertInstanceOf(PackageInfo::class, $standaloneFluid); + self::assertSame('typo3fluid/fluid', $standaloneFluid->getName(), 'PackageInfo->name is not "typo3fluid/fluid"'); + self::assertFalse($standaloneFluid->isSystemExtension(), '"typo3fluid/fluid" is not a TYPO3 system extension'); + self::assertFalse($standaloneFluid->isExtension(), '"typo3fluid/fluid" is not a TYPO3 extension'); + self::assertTrue($standaloneFluid->isComposerPackage(), '"typo3fluid/fluid" is a composer package'); + self::assertSame('', $standaloneFluid->getExtensionKey()); + + // Verify TYPO3 system extension fluid. + $coreFluid = $composerManager->getPackageInfo('typo3/cms-fluid'); + self::assertInstanceOf(PackageInfo::class, $coreFluid); + self::assertSame('typo3/cms-fluid', $coreFluid->getName(), 'PackageInfo->name is not "typo3/cms-fluid"'); + self::assertTrue($coreFluid->isSystemExtension(), '"typo3/cms-fluid" is a TYPO3 system extension'); + self::assertFalse($coreFluid->isExtension(), '"typo3/cms-fluid" is not a TYPO3 extension'); + self::assertTrue($coreFluid->isComposerPackage(), '"typo3/cms-fluid" is a composer package'); + self::assertSame('fluid', $coreFluid->getExtensionKey()); + + // Verify TYPO3 system extension fluid resolved using extension key. + $extensionKeyRetrievesCoreFluid = $composerManager->getPackageInfo('fluid'); + self::assertInstanceOf(PackageInfo::class, $extensionKeyRetrievesCoreFluid); + self::assertSame('typo3/cms-fluid', $extensionKeyRetrievesCoreFluid->getName(), 'PackageInfo->name is not "typo3/cms-fluid"'); + self::assertTrue($extensionKeyRetrievesCoreFluid->isSystemExtension(), '"typo3/cms-fluid" is a TYPO3 system extension'); + self::assertFalse($extensionKeyRetrievesCoreFluid->isExtension(), '"typo3/cms-fluid" is not a TYPO3 extension'); + self::assertTrue($extensionKeyRetrievesCoreFluid->isComposerPackage(), '"typo3/cms-fluid" is a composer package'); + self::assertSame('fluid', $extensionKeyRetrievesCoreFluid->getExtensionKey()); + + // Verify TYPO3 system extension fluid resolved using relative classic mode path. + $extensionRelativeSystemExtensionPath = $composerManager->getPackageInfo('typo3/sysext/fluid'); + self::assertInstanceOf(PackageInfo::class, $extensionRelativeSystemExtensionPath); + self::assertSame('typo3/cms-fluid', $extensionRelativeSystemExtensionPath->getName(), 'PackageInfo->name is not "typo3/cms-fluid"'); + self::assertTrue($extensionRelativeSystemExtensionPath->isSystemExtension(), '"typo3/cms-fluid" is a TYPO3 system extension'); + self::assertFalse($extensionRelativeSystemExtensionPath->isExtension(), '"typo3/cms-fluid" is not a TYPO3 extension'); + self::assertTrue($extensionRelativeSystemExtensionPath->isComposerPackage(), '"typo3/cms-fluid" is a composer package'); + self::assertSame('fluid', $extensionRelativeSystemExtensionPath->getExtensionKey()); + } } diff --git a/Tests/Unit/Composer/Fixtures/Extensions/extension-key-shared-with-composer-package/composer.json b/Tests/Unit/Composer/Fixtures/Extensions/extension-key-shared-with-composer-package/composer.json new file mode 100644 index 00000000..60065539 --- /dev/null +++ b/Tests/Unit/Composer/Fixtures/Extensions/extension-key-shared-with-composer-package/composer.json @@ -0,0 +1,18 @@ +{ + "name": "testing-framework/extension-key-shared-with-composer-package", + "description": "TYPO3 extension shareing extension-key with last part of composer package ", + "type": "typo3-cms-extension", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Stefan Bürk", + "email": "stefan@buerk.tech" + } + ], + "require": {}, + "extra": { + "typo3/cms": { + "extension-key": "sharedextensionkey" + } + } +} diff --git a/Tests/Unit/Composer/Fixtures/Packages/sharedextensionkey/composer.json b/Tests/Unit/Composer/Fixtures/Packages/sharedextensionkey/composer.json new file mode 100644 index 00000000..de1adcd0 --- /dev/null +++ b/Tests/Unit/Composer/Fixtures/Packages/sharedextensionkey/composer.json @@ -0,0 +1,12 @@ +{ + "name": "testing-framework/sharedextensionkey", + "description": "TYPO3 extension shareing extension-key with last part of composer package ", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Stefan Bürk", + "email": "stefan@buerk.tech" + } + ], + "require": {} +} From c627c85f18ab00f328dc7858a723df300897e13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Thu, 28 Nov 2024 21:03:25 +0100 Subject: [PATCH 22/29] [BUGFIX] Avoid TypeError for database port handling Using the `typo3DatabasePort` environment variable to define the database server port string-casts the value to an string which is not compatible with the mysqli method `mysqli::real_connect()` and fails with: TypeError: mysqli::real_connect(): Argument #5 ($port) must be of type ?int, string given This change casts the database port to an integer in case a port is provided to avoid PHP TypeError when passing to `mysqli::real_connect()` to align with `ConnectionPool`, which is not used to create the database for the functional test instance. Resolves: #631 Releases: main, 8 --- Classes/Core/Testbase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 45244563..d1cfa339 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -473,7 +473,7 @@ public function getOriginalDatabaseSettingsFromEnvironmentOrLocalConfiguration(a $originalConfigurationArray['DB']['Connections']['Default']['password'] = $databasePasswordTrimmed; } if ($databasePort) { - $originalConfigurationArray['DB']['Connections']['Default']['port'] = $databasePort; + $originalConfigurationArray['DB']['Connections']['Default']['port'] = (int)$databasePort; } if ($databaseSocket) { $originalConfigurationArray['DB']['Connections']['Default']['unix_socket'] = $databaseSocket; From dd7505bccd20257c4ba5d74747cfbd88e3e52139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 29 Nov 2024 15:15:49 +0100 Subject: [PATCH 23/29] [BUGFIX] Ensure to take test instance as class mode instance The bugfix backport to ensure correct system environment path building to fix issue #577 missed to correctly set the flag for non-composer mode for TYPO3 v12 and lead to wrong path calculations and followup issues within functional tests. This is fixed by handing over a correct override value within the functional test bootstrap. [1] https://github.com/TYPO3/testing-framework/issues/577 [2] https://github.com/TYPO3/testing-framework/pull/633 Resolves: #658 Related: #633 Related: https://github.com/TYPO3/testing-framework/pull/633/commits/409c2b8e4551a9bdb40703e09c48ffcea2d74c42 Releases: 8 --- Classes/Core/Testbase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index d1cfa339..62513aca 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -747,7 +747,7 @@ public function setUpBasicTypo3Bootstrap($instancePath): ContainerInterface if ($hasConsolidatedHttpEntryPoint) { SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_CLI, false); } else { - SystemEnvironmentBuilder::run(1, SystemEnvironmentBuilder::REQUESTTYPE_BE | SystemEnvironmentBuilder::REQUESTTYPE_CLI); + SystemEnvironmentBuilder::run(1, SystemEnvironmentBuilder::REQUESTTYPE_BE | SystemEnvironmentBuilder::REQUESTTYPE_CLI, false); } $container = Bootstrap::init($classLoader); // Make sure output is not buffered, so command-line output can take place and From 5dba0f3bf44559f8932a153257075660daabed51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Mon, 2 Dec 2024 15:30:32 +0100 Subject: [PATCH 24/29] [BUGFIX] Enforce classic mode in created TYPO3 entrypoint files `typo3/testing-framework` provides a extended `SystemEnvironmentBuilder` to ensure correct instance initialization as classic mode in different test context environment, allowing to manual set the composer mode flag despite having the PHP define from parent composer installation as source. `Testbase->setUpInstanceCoreLinks()` additionally provides TYPO3 entrypoints in form of index.php files, calling the basic bootstrap, based on template files from system extensions and modified to use the `typo3/testing-framework` `SystemEnvironmentBuilder` but does not enforce non-composer (classic) mode. This does not hurt within functional tests but codeception based accceptance instances misses the enforced classic mode which can lead to several issues, for example normalizedParams path calculation as basis for additional path or link generation. This change modifies the entrypoint creation code to reflect this need and forces non-composer mode. Releases: main, 8 --- Classes/Core/Testbase.php | 47 ++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 62513aca..88b824ec 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -239,23 +239,50 @@ public function setUpInstanceCoreLinks( $installPhpExists = file_exists($instancePath . '/typo3/sysext/install/Resources/Private/Php/install.php'); if ($hasConsolidatedHttpEntryPoint) { $entryPointsToSet = [ - $instancePath . '/typo3/sysext/core/Resources/Private/Php/index.php' => $instancePath . '/index.php', + [ + 'source' => $instancePath . '/typo3/sysext/core/Resources/Private/Php/index.php', + 'target' => $instancePath . '/index.php', + 'pattern' => '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(\);/', + 'replacement' => '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(0, 0, false);', + ], ]; if ($installPhpExists && in_array('install', $coreExtensions, true)) { - $entryPointsToSet[$instancePath . '/typo3/sysext/install/Resources/Private/Php/install.php'] = $instancePath . '/typo3/install.php'; + $entryPointsToSet[] = [ + 'source' => $instancePath . '/typo3/sysext/install/Resources/Private/Php/install.php', + 'target' => $instancePath . '/typo3/install.php', + 'pattern' => '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(1\);/', + 'replacement' => '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(1, 0, false);', + ]; } } else { $entryPointsToSet = [ - $instancePath . '/typo3/sysext/backend/Resources/Private/Php/backend.php' => $instancePath . '/typo3/index.php', - $instancePath . '/typo3/sysext/frontend/Resources/Private/Php/frontend.php' => $instancePath . '/index.php', + [ + 'source' => $instancePath . '/typo3/sysext/backend/Resources/Private/Php/backend.php', + 'target' => $instancePath . '/typo3/index.php', + 'pattern' => '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(1, \\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::REQUESTTYPE_BE\);/', + 'replacement' => '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(1, \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_BE, false);', + ], + [ + 'source' => $instancePath . '/typo3/sysext/frontend/Resources/Private/Php/frontend.php', + 'target' => $instancePath . '/index.php', + 'pattern' => '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(0, \\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::REQUESTTYPE_FE\);/', + 'replacement' => '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(0, \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_FE, false);', + ], ]; if ($installPhpExists) { - $entryPointsToSet[$instancePath . '/typo3/sysext/install/Resources/Private/Php/install.php'] = $instancePath . '/typo3/install.php'; + $entryPointsToSet[] = [ + 'source' => $instancePath . '/typo3/sysext/install/Resources/Private/Php/install.php', + 'target' => $instancePath . '/typo3/install.php', + 'pattern' => '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(1, \\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::REQUESTTYPE_INSTALL\);/', + 'replacement' => '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(1, \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_INSTALL, false);', + ]; } } $autoloadFile = dirname(__DIR__, 4) . '/autoload.php'; - foreach ($entryPointsToSet as $source => $target) { + foreach ($entryPointsToSet as $entryPointToSet) { + $source = $entryPointToSet['source']; + $target = $entryPointToSet['target']; if (($entryPointContent = file_get_contents($source)) === false) { throw new \UnexpectedValueException(sprintf('Source file (%s) was not found.', $source), 1636244753); } @@ -264,11 +291,9 @@ public function setUpInstanceCoreLinks( $this->findShortestPathCode($target, $autoloadFile), $entryPointContent ); - $entryPointContent = (string)preg_replace( - '/\\\\TYPO3\\\\CMS\\\\Core\\\\Core\\\\SystemEnvironmentBuilder::run\(/', - '\TYPO3\TestingFramework\Core\SystemEnvironmentBuilder::run(', - $entryPointContent - ); + $pattern = $entryPointToSet['pattern']; + $replacement = $entryPointToSet['replacement']; + $entryPointContent = (string)preg_replace($pattern, $replacement, $entryPointContent); if ($entryPointContent === '') { throw new \UnexpectedValueException( sprintf('Error while customizing the source file (%s).', $source), From 7396ef0e3761da635580e3d767aa79afb2fa9f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Thu, 5 Dec 2024 12:15:17 +0100 Subject: [PATCH 25/29] [TASK] Allow defining test instance files copy for acceptance tests Running acceptance tests with codeception on a created test instance with a real `Apache2` webserver requires to have the `.htaccess` files in place, otherwise the required rewriting to the endpoints will not work. Since TYPO3 v13 with droppend backend entrypoint this grows to a even higher requirement. TYPO3 monorepo implemented that directly within the extended `BackendEnvironment` class. To make the live for developers easier using the `typo3/testing-framework` for project or extension acceptance testing a new tooling is now added based on the direct monorepo implementation. Following `BackendEnvironment::$config[]` options are now available: * `'copyInstanceFiles' => [],` (`array`) to copy the soureFile to all listed target paths. * `'copyInstanceFilesCreateTargetPath' => true,` to configure if target folders should be created when missing or throw a excetion. `BackendEnvironment` applies default files to copy based on available core extensions: * `EXT:backend` source.: 'typo3/sysext/backend/Resources/Public/Icons/favicon.ico' targets: - 'favicon.ico' * `EXT:install` source.: 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/root-htaccess' targets: - '.htaccess' source.: 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-htaccess' targets: - 'fileadmin/_temp_/.htaccess' source.: 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-index.html' targets: - 'fileadmin/_temp_/index.html' source.: 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/typo3temp-var-htaccess' targets: . 'typo3temp/var/.htaccess', That way, additional files could be defined and configured instead of implementing custom code in the extended class. Note that files are always provided, which does not hurt when not using `Apache2` as acceptance instance webserver. Releases: main, 8 --- .../Extension/BackendEnvironment.php | 119 +++++++++++++- Classes/Core/Testbase.php | 148 +++++++++++++++++- 2 files changed, 265 insertions(+), 2 deletions(-) diff --git a/Classes/Core/Acceptance/Extension/BackendEnvironment.php b/Classes/Core/Acceptance/Extension/BackendEnvironment.php index 35d7bdad..736eaefa 100644 --- a/Classes/Core/Acceptance/Extension/BackendEnvironment.php +++ b/Classes/Core/Acceptance/Extension/BackendEnvironment.php @@ -22,7 +22,9 @@ use Codeception\Extension; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Information\Typo3Version; +use TYPO3\CMS\Core\Utility\ArrayUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Composer\ComposerPackageManager; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\DataSet; use TYPO3\TestingFramework\Core\Testbase; @@ -148,6 +150,19 @@ abstract class BackendEnvironment extends Extension * Example: [ __DIR__ . '/../../Fixtures/BackendEnvironment.csv' ] */ 'csvDatabaseFixtures' => [], + + /** + * Copy files within created test instance. + * + * @var array + */ + 'copyInstanceFiles' => [], + + /** + * Should target paths for self::$config['copyInstanceFiles'] be created. + * Will throw an exception if folder does not exists and set to `false`. + */ + 'copyInstanceFilesCreateTargetPath' => true, ]; /** @@ -171,6 +186,11 @@ abstract class BackendEnvironment extends Extension Events::TEST_BEFORE => 'cleanupTypo3Environment', ]; + /** + * @var string|null Test instance path when created. + */ + protected ?string $instancePath = null; + /** * Initialize config array, called before events. * @@ -231,7 +251,7 @@ public function bootstrapTypo3Environment(SuiteEvent $suiteEvent) $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/tests/acceptance'); $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/transient'); - $instancePath = ORIGINAL_ROOT . 'typo3temp/var/tests/acceptance'; + $instancePath = $this->instancePath = ORIGINAL_ROOT . 'typo3temp/var/tests/acceptance'; putenv('TYPO3_PATH_ROOT=' . $instancePath); putenv('TYPO3_PATH_APP=' . $instancePath); $testbase->setTypo3TestingContext(); @@ -312,6 +332,13 @@ public function bootstrapTypo3Environment(SuiteEvent $suiteEvent) // @todo: See which other possible state should be dropped here again (singletons, ...?) restore_error_handler(); + $this->applyDefaultCopyInstanceFilesConfiguration(); + $copyInstanceFilesCreateTargetPaths = (bool)($this->config['copyInstanceFilesCreateTargetPath'] ?? true); + $copyInstanceFiles = $this->config['copyInstanceFiles'] ?? []; + if (is_array($copyInstanceFiles) && $copyInstanceFiles !== []) { + $testbase->copyInstanceFiles($instancePath, $copyInstanceFiles, $copyInstanceFilesCreateTargetPaths); + } + // Unset a closure or phpunit kicks in with a 'serialization of \Closure is not allowed' // Alternative solution: // unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['GLOBAL']['cliKeys']['extbase']); @@ -337,4 +364,94 @@ public function cleanupTypo3Environment() ->getConnectionForTable('be_users') ->update('be_users', ['uc' => null], ['uid' => 1]); } + + private function applyDefaultCopyInstanceFilesConfiguration(): void + { + if ($this->hasExtension('typo3/cms-backend')) { + ArrayUtility::mergeRecursiveWithOverrule( + $this->config, + [ + 'copyInstanceFiles' => [ + // Create favicon.ico to suppress potential javascript errors in console + // which are caused by calling a non html in the browser, e.g. seo sitemap xml + 'typo3/sysext/backend/Resources/Public/Icons/favicon.ico' => [ + 'favicon.ico', + ], + ], + ] + ); + } + if ($this->hasExtension('typo3/cms-install')) { + ArrayUtility::mergeRecursiveWithOverrule( + $this->config, + [ + 'copyInstanceFiles' => [ + // Provide some files into the test instance normally added by installer + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/root-htaccess' => [ + '.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/resources-root-htaccess' => [ + 'fileadmin/.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-htaccess' => [ + 'fileadmin/_temp_/.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-index.html' => [ + 'fileadmin/_temp_/index.html', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/typo3temp-var-htaccess' => [ + 'typo3temp/var/.htaccess', + ], + ], + ] + ); + } + } + + /** + * Verify if extension is available in the system and within the acceptance test instance. + */ + protected function hasExtension(string $extensionKeyOrComposerPackageName): bool + { + $instanceExtensions = $this->getInstanceExtensionKeys( + $this->config['coreExtensionsToLoad'], + $this->config['testExtensionsToLoad'], + ); + $packageInfo = (new ComposerPackageManager())->getPackageInfo($extensionKeyOrComposerPackageName); + if ($packageInfo === null) { + return false; + } + return $packageInfo->getExtensionKey() !== '' && in_array($packageInfo->getExtensionKey(), $instanceExtensions, true); + } + + /** + * Gather list of extension keys available within created test instance + * based on `coreExtensionsToLoad` and `testExtensionToLoad` config. + */ + private function getInstanceExtensionKeys( + array $coreExtensionsToLoad, + array $testExtensionsToLoad, + ): array { + $composerPackageManager = new ComposerPackageManager(); + if ($coreExtensionsToLoad === []) { + // Fallback to all system extensions needed for TYPO3 acceptanceInstall tests. + $coreExtensionsToLoad = $composerPackageManager->getSystemExtensionExtensionKeys(); + } + $result = []; + foreach ($coreExtensionsToLoad as $extensionKeyOrComposerPackageName) { + $packageInfo = $composerPackageManager->getPackageInfo($extensionKeyOrComposerPackageName); + if ($packageInfo === null || $packageInfo->getExtensionKey() === '') { + continue; + } + $result[] = $packageInfo->getExtensionKey(); + } + foreach ($testExtensionsToLoad as $extensionKeyOrComposerPackageName) { + $packageInfo = $composerPackageManager->getPackageInfo($extensionKeyOrComposerPackageName); + if ($packageInfo === null || $packageInfo->getExtensionKey() === '') { + continue; + } + $result[] = $packageInfo->getExtensionKey(); + } + return $result; + } } diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 88b824ec..00776e6c 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -207,7 +207,7 @@ public function setUpInstanceCoreLinks( $linksToSet = []; $coreExtensions = array_unique(array_merge($defaultCoreExtensionsToLoad, $coreExtensionsToLoad)); - // @todo Fallback to all system extensions needed for TYPO3 acceptanceInstall tests. + // Fallback to all system extensions needed for TYPO3 acceptanceInstall tests. if ($coreExtensions === []) { $coreExtensions = $this->composerPackageManager->getSystemExtensionExtensionKeys(); } @@ -304,6 +304,46 @@ public function setUpInstanceCoreLinks( } } + public function provideInstance(array $additionalHtaccessFiles = []): void + { + $copyFiles = [ + // Create favicon.ico to suppress potential javascript errors in console + // which are caused by calling a non html in the browser, e.g. seo sitemap xml + 'typo3/sysext/backend/Resources/Public/Icons/favicon.ico' => [ + 'favicon.ico', + ], + // Provide some files into the test instance normally added by installer + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/root-htaccess' => [ + '.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/resources-root-htaccess' => [ + 'fileadmin/.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-htaccess' => [ + 'fileadmin/_temp_/.htaccess', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/fileadmin-temp-index.html' => [ + 'fileadmin/_temp_/index.html', + ], + 'typo3/sysext/install/Resources/Private/FolderStructureTemplateFiles/typo3temp-var-htaccess' => [ + 'typo3temp/var/.htaccess', + ], + ]; + foreach ($copyFiles as $sourceFile => $targetFiles) { + foreach ($targetFiles as $targetFile) { + $this->createDirectory(dirname(ltrim($targetFile, '/'))); + $sourceFile = ltrim($sourceFile, '/'); + $targetFile = ltrim($targetFile, '/'); + if (!@copy($sourceFile, $targetFile)) { + throw new \RuntimeException( + sprintf('Could not copy "%s" to "%s".', $sourceFile, $targetFile), + 1733391799, + ); + } + } + } + } + /** * Link test extensions to the typo3conf/ext folder of the instance. * For functional and acceptance tests. @@ -1063,4 +1103,110 @@ private static function isSQLite(Connection|DoctrineConnection $connection): boo { return $connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform; } + + /** + * Copy files within the test instance path `$instancePath`. + * + * @param string $instancePath The test instance path. + * @param array $files + * @throws Exception + */ + public function copyInstanceFiles(string $instancePath, array $files, bool $createTargetFolder = true): void + { + if ($files === []) { + return; + } + foreach ($files as $sourceFile => $targetFiles) { + foreach ($targetFiles as $targetFile) { + $this->copyInstanceFile($instancePath, $sourceFile, $targetFile, $createTargetFolder); + } + } + } + + /** + * Copy one file within the test instance with the option to create the target path. + * + * @param string $instancePath The test instance path. + * @param string $sourceFile Relative source file path within test instance. + * @param string $targetFile Target file path within test instance. + * @param bool $createTargetFolder True to create target folder it does not exists, otherwise exception is thrown. + * @throws Exception + */ + public function copyInstanceFile(string $instancePath, string $sourceFile, string $targetFile, bool $createTargetFolder = true): void + { + if (str_starts_with($sourceFile, '/')) { + throw new \RuntimeException( + sprintf( + 'Source "%s" must be relative from test instance path and must not start with "/".', + $sourceFile, + ), + 1733392183, + ); + } + if (str_starts_with($targetFile, '/')) { + throw new \RuntimeException( + sprintf( + 'Target "%s" must be relative from test instance path and must not start with "/".', + $targetFile, + ), + 1733392258, + ); + } + if (trim($sourceFile, '/') === '') { + throw new \RuntimeException( + sprintf( + 'Source "%s" must not be empty or "/".', + $sourceFile, + ), + 1733392321, + ); + } + if (trim($targetFile, '/') === '') { + throw new \RuntimeException( + sprintf( + 'Target "%s" must not be empty or "/".', + $targetFile, + ), + 1733392321, + ); + } + $instancePath = rtrim($instancePath, '/'); + $sourceFileFull = $instancePath . '/' . $sourceFile; + $targetFileFull = $instancePath . '/' . $targetFile; + $targetPath = rtrim(dirname($targetFile), '/'); + $targetPathFull = $instancePath . '/' . $targetPath; + if (!is_dir($targetPathFull)) { + if (!$createTargetFolder) { + throw new \RuntimeException( + sprintf( + 'Target instance path "%s" does not exists and should not be created, but is required.', + $targetPath, + ), + 1733392917, + ); + } + $this->createDirectory($targetPathFull); + } + if (!file_exists($sourceFileFull)) { + throw new \RuntimeException( + sprintf( + 'Source file "%s" does not exists within test instance "%s" and could not be copied to "%s".', + $sourceFile, + $instancePath, + $targetFile, + ), + 1733393186, + ); + } + if (!@copy($sourceFileFull, $targetFileFull)) { + throw new \RuntimeException( + sprintf( + 'Could not copy "%s" to "%s".', + $sourceFile, + $targetFile, + ), + 1733391799, + ); + } + } } From e2ad5e8bf643bbea2d92c31f61c859c4876f4b1f Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Thu, 10 Apr 2025 12:33:27 +0200 Subject: [PATCH 26/29] [BUGFIX] Allow to process versions of language variants This change allows for YAML-based fixtures to also add version variants for a language variant thus, making it fully overlayable. See https://review.typo3.org/c/Packages/TYPO3.CMS/+/72015 Backport of #669 --- .../DataHandling/Scenario/DataHandlerFactory.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php index 04b8fb0d..de29d462 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php +++ b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php @@ -220,7 +220,14 @@ private function processLanguageVariantItem( if (isset($itemSettings['actions'])) { $this->setInCommandMap($tableName, $newId, $nodeId, $itemSettings['actions'], (int)$workspaceId); } - + foreach ($itemSettings['versionVariants'] ?? [] as $versionVariantSettings) { + $this->processVersionVariantItem( + $entityConfiguration, + $versionVariantSettings, + $newId, + $nodeId + ); + } foreach ($itemSettings['languageVariants'] ?? [] as $variantItemSettings) { $this->processLanguageVariantItem( $entityConfiguration, From 23966b638881fd8756bb7c4400801c1d2642b2ae Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Tue, 22 Apr 2025 13:56:30 +0200 Subject: [PATCH 27/29] [TASK] Improve error message (#671) When a versioned record cannot be found, the DB table and the UID of the live record is now added to the message of the exception. --- .../Core/Functional/Framework/DataHandling/ActionService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Core/Functional/Framework/DataHandling/ActionService.php b/Classes/Core/Functional/Framework/DataHandling/ActionService.php index 79f67354..774b24ae 100644 --- a/Classes/Core/Functional/Framework/DataHandling/ActionService.php +++ b/Classes/Core/Functional/Framework/DataHandling/ActionService.php @@ -430,7 +430,7 @@ public function publishRecords(array $tableLiveUids, bool $throwException = true $versionedUid = $this->getVersionedId($tableName, $liveUid); if (empty($versionedUid)) { if ($throwException) { - throw new Exception('Versioned UID could not be determined', 1476049592); + throw new Exception('Versioned UID of ' . $tableName . ':' . $liveUid . ' could not be determined', 1476049592); } continue; } From a9f728c8ef88cd6c3ddaf474ecdb86f5d89b8b32 Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Tue, 12 Aug 2025 17:11:01 +0200 Subject: [PATCH 28/29] [TASK] Greenify phpstan (#676) --- Build/phpstan/phpstan-baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Build/phpstan/phpstan-baseline.neon b/Build/phpstan/phpstan-baseline.neon index d7df627a..493c18f2 100644 --- a/Build/phpstan/phpstan-baseline.neon +++ b/Build/phpstan/phpstan-baseline.neon @@ -3,7 +3,7 @@ parameters: - message: '#^Offset 0 on non\-empty\-list\ on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset - count: 1 + count: 2 path: ../../Classes/Composer/ComposerPackageManager.php - From 8d5ac7fdd65d5a884d7182fae16266a51552061b Mon Sep 17 00:00:00 2001 From: Christian Kuhn Date: Tue, 12 Aug 2025 17:24:13 +0200 Subject: [PATCH 29/29] [TASK] Add PHP 8.5 to test matrix (#679) --- .github/workflows/ci.yml | 2 +- Build/Scripts/runTests.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd72fe3..8f9c4dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1' , '8.2', '8.3', '8.4'] + php: [ '8.1' , '8.2', '8.3', '8.4', '8.5' ] steps: - name: Checkout ${{ github.event_name == 'workflow_dispatch' && github.head_ref || '' }} diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 0c7e789a..341e889f 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -45,6 +45,7 @@ Options: - 8.2: use PHP 8.2 - 8.3: use PHP 8.3 - 8.4: use PHP 8.4 + - 8.5: use PHP 8.5 -x Only with -s cgl|unit @@ -118,7 +119,7 @@ while getopts ":b:s:p:hxn" OPT; do ;; p) PHP_VERSION=${OPTARG} - if ! [[ ${PHP_VERSION} =~ ^(8.1|8.2|8.3|8.4)$ ]]; then + if ! [[ ${PHP_VERSION} =~ ^(8.1|8.2|8.3|8.4|8.5)$ ]]; then INVALID_OPTIONS+=("${OPTARG}") fi ;;