diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67bdca40..8f9c4dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,18 +12,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1' , '8.2', '8.3' ] + php: [ '8.1' , '8.2', '8.3', '8.4', '8.5' ] 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 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 diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 40a52cbb..341e889f 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 } @@ -42,6 +44,8 @@ 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 + - 8.5: use PHP 8.5 -x Only with -s cgl|unit @@ -115,7 +119,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|8.5)$ ]]; then INVALID_OPTIONS+=("${OPTARG}") fi ;; @@ -210,7 +214,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=$? ;; 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/Build/phpstan/phpstan-baseline.neon b/Build/phpstan/phpstan-baseline.neon index 364905f7..493c18f2 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: 2 + 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/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 c06015af..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 @@ -47,7 +61,7 @@ final class ComposerPackageManager private static string $publicPath = ''; - private static PackageInfo|null $rootPackage = null; + private static ?PackageInfo $rootPackage = null; /** * @var array @@ -61,28 +75,33 @@ final class ComposerPackageManager public function __construct() { + // @todo Remove this from the constructor. $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; } /** @@ -402,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; } /** @@ -545,11 +578,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-') @@ -630,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/Acceptance/Extension/BackendEnvironment.php b/Classes/Core/Acceptance/Extension/BackendEnvironment.php index a073b645..736eaefa 100644 --- a/Classes/Core/Acceptance/Extension/BackendEnvironment.php +++ b/Classes/Core/Acceptance/Extension/BackendEnvironment.php @@ -21,7 +21,10 @@ use Codeception\Events; 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; @@ -147,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, ]; /** @@ -170,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. * @@ -230,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(); @@ -261,8 +282,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. @@ -304,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']); @@ -329,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/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..774b24ae 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 => [ @@ -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; } 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); diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php index 3f86913c..de29d462 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, @@ -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, @@ -240,7 +247,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 +291,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/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']; } 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/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; 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..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[] */ @@ -310,6 +351,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 +374,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'] = 'utf8'; } } 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'] = 'utf8'; + } } // Set some hard coded base settings for the instance. Those could be overruled by @@ -898,7 +974,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..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'] ); @@ -82,7 +114,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 +129,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 +149,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(); @@ -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/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 9b04635d..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(); } @@ -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), @@ -279,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. @@ -473,7 +538,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; @@ -668,10 +733,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 +758,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. @@ -741,9 +810,9 @@ 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); + 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 @@ -1034,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, + ); + } + } } 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 @@ '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' => [], diff --git a/Tests/Unit/Composer/ComposerPackageManagerTest.php b/Tests/Unit/Composer/ComposerPackageManagerTest.php index bdf4e742..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()); @@ -295,4 +357,262 @@ 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()); + } + + 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": {} +} 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" + } + } +} diff --git a/composer.json b/composer.json index c0f79ee6..e999f4e1 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", @@ -58,9 +59,9 @@ } }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.37.1", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-phpunit": "^1.1.1", + "friendsofphp/php-cs-fixer": "^3.65.0", + "phpstan/phpstan": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.1", "typo3/cms-workspaces": "12.*.*@dev || 13.*.*@dev" }, "replace": {