diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2e2e05a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: [push, pull_request] + +jobs: + + testsuite: + name: all tests + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '7.2', '7.3', '7.4' ] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Composer install + run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s composerInstall + + - name: Lint PHP + run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s lint + + - name: Unit Tests + run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s unit diff --git a/.gitignore b/.gitignore index 71cef2d3..30187ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea/ composer.lock public/ +Build/testing-docker/.env diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e95db0e5..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: php - -php: - - 7.2 - - 7.3 - -cache: - directories: - - $HOME/.composer/cache - -install: - - composer install - -script: - - > - echo; - echo "Running unit tests"; - echo; - echo; - .Build/bin/phpunit Tests/Unit/; - - - > - echo; - echo "Running php lint"; - echo; - echo; - find . -name \*.php ! -path "./.Build/*" ! -path "./public/*" -exec php -l {} >/dev/null \; - diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh new file mode 100755 index 00000000..d6391564 --- /dev/null +++ b/Build/Scripts/runTests.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +# +# Test runner based on docker and docker-compose. +# + +# Function to write a .env file in Build/testing-docker/local +# This is read by docker-compose and vars defined here are +# used in Build/testing-docker/docker-compose.yml +setUpDockerComposeDotEnv() { + # Delete possibly existing local .env file if exists + [ -e .env ] && rm .env + # Set up a new .env file for docker-compose + echo "COMPOSE_PROJECT_NAME=local" >> .env + # To prevent access rights of files created by the testing, the docker image later + # runs with the same user that is currently executing the script. docker-compose can't + # use $UID directly itself since it is a shell variable and not an env variable, so + # we have to set it explicitly here. + echo "HOST_UID=`id -u`" >> .env + # Your local home directory for composer and npm caching + echo "HOST_HOME=${HOME}" >> .env + # Your local user + echo "ROOT_DIR"=${ROOT_DIR} >> .env + echo "HOST_USER=${USER}" >> .env + echo "DOCKER_PHP_IMAGE=${DOCKER_PHP_IMAGE}" >> .env + echo "SCRIPT_VERBOSE=${SCRIPT_VERBOSE}" >> .env +} + +# Load help text into $HELP +read -r -d '' HELP < + Specifies which test suite to run + - composerInstall: "composer install" + - lint: PHP linting + - unit (default): PHP unit tests + + -p <7.2|7.3|7.4> + Specifies the PHP minor version to be used + - 7.2 (default): use PHP 7.2 + - 7.3: use PHP 7.3 + - 7.4: use PHP 7.4 + + -v + Enable verbose script output. Shows variables and docker commands. + + -h + Show this help. + +Examples: + # Run unit tests using default PHP version + ./Build/Scripts/runTests.sh + + # Run unit tests using PHP 7.4 + ./Build/Scripts/runTests.sh -p 7.4 +EOF + +# Test if docker-compose exists, else exit out with error +if ! type "docker-compose" > /dev/null; then + echo "This script relies on docker and docker-compose. Please install" >&2 + exit 1 +fi + +# Go to the directory this script is located, so everything else is relative +# to this dir, no matter from where this script is called. +THIS_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +cd "$THIS_SCRIPT_DIR" || exit 1 + +# Go to directory that contains the local docker-compose.yml file +cd ../testing-docker || exit 1 + +# Option defaults +ROOT_DIR=`readlink -f ${PWD}/../../` +TEST_SUITE="unit" +PHP_VERSION="7.2" +SCRIPT_VERBOSE=0 + +# Option parsing +# Reset in case getopts has been used previously in the shell +OPTIND=1 +# Array for invalid options +INVALID_OPTIONS=(); +# Simple option parsing based on getopts (! not getopt) +while getopts ":s:p:hv" OPT; do + case ${OPT} in + s) + TEST_SUITE=${OPTARG} + ;; + p) + PHP_VERSION=${OPTARG} + ;; + h) + echo "${HELP}" + exit 0 + ;; + v) + SCRIPT_VERBOSE=1 + ;; + \?) + INVALID_OPTIONS+=(${OPTARG}) + ;; + :) + INVALID_OPTIONS+=(${OPTARG}) + ;; + esac +done + +# Exit on invalid options +if [ ${#INVALID_OPTIONS[@]} -ne 0 ]; then + echo "Invalid option(s):" >&2 + for I in "${INVALID_OPTIONS[@]}"; do + echo "-"${I} >&2 + done + echo >&2 + echo "${HELP}" >&2 + exit 1 +fi + +# Move "7.2" to "php72", the latter is the docker container name +DOCKER_PHP_IMAGE=`echo "php${PHP_VERSION}" | sed -e 's/\.//'` + +if [ ${SCRIPT_VERBOSE} -eq 1 ]; then + set -x +fi + +# Suite execution +case ${TEST_SUITE} in + cgl) + # Active dry-run for cgl needs not "-n" but specific options + if [[ ! -z ${CGLCHECK_DRY_RUN} ]]; then + CGLCHECK_DRY_RUN="--dry-run --diff --diff-format udiff" + fi + setUpDockerComposeDotEnv + docker-compose run cgl + SUITE_EXIT_CODE=$? + docker-compose down + ;; + composerInstall) + setUpDockerComposeDotEnv + docker-compose run composer_install + SUITE_EXIT_CODE=$? + docker-compose down + ;; + lint) + setUpDockerComposeDotEnv + docker-compose run lint + SUITE_EXIT_CODE=$? + docker-compose down + ;; + unit) + setUpDockerComposeDotEnv + docker-compose run unit + SUITE_EXIT_CODE=$? + docker-compose down + ;; + *) + echo "Invalid -s option argument ${TEST_SUITE}" >&2 + echo >&2 + echo "${HELP}" >&2 + exit 1 +esac + +exit $SUITE_EXIT_CODE \ No newline at end of file diff --git a/Build/testing-docker/docker-compose.yml b/Build/testing-docker/docker-compose.yml new file mode 100644 index 00000000..df3f2e19 --- /dev/null +++ b/Build/testing-docker/docker-compose.yml @@ -0,0 +1,54 @@ +version: '2.3' +services: + composer_install: + image: typo3/core-testing-${DOCKER_PHP_IMAGE}:latest + user: ${HOST_UID} + volumes: + - ${ROOT_DIR}:${ROOT_DIR} + - ${HOST_HOME}:${HOST_HOME} + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro + working_dir: ${ROOT_DIR} + command: > + /bin/sh -c " + if [ ${SCRIPT_VERBOSE} -eq 1 ]; then + set -x + fi + php -v | grep '^PHP'; + composer install --no-progress --no-interaction; + " + lint: + image: typo3/core-testing-${DOCKER_PHP_IMAGE}:latest + user: ${HOST_UID} + volumes: + - ${ROOT_DIR}:${ROOT_DIR} + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro + working_dir: ${ROOT_DIR} + command: > + /bin/sh -c " + if [ ${SCRIPT_VERBOSE} -eq 1 ]; then + set -x + fi + php -v | grep '^PHP'; + find . -name \\*.php ! -path "./.Build/\\*" ! -path "./public/\\*" -print0 | xargs -0 -n1 -P4 php -dxdebug.mode=off -l >/dev/null + " + + unit: + image: typo3/core-testing-${DOCKER_PHP_IMAGE}:latest + user: ${HOST_UID} + volumes: + - ${ROOT_DIR}:${ROOT_DIR} + - ${HOST_HOME}:${HOST_HOME} + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro + working_dir: ${ROOT_DIR}/.Build + command: > + /bin/sh -c " + if [ ${SCRIPT_VERBOSE} -eq 1 ]; then + set -x + fi + php -v | grep '^PHP'; + cd ..; + php -dxdebug.mode=off .Build/bin/phpunit Tests/Unit/; + " diff --git a/Classes/Core/Acceptance/Helper/AbstractPageTree.php b/Classes/Core/Acceptance/Helper/AbstractPageTree.php index caee65f8..389cfc83 100644 --- a/Classes/Core/Acceptance/Helper/AbstractPageTree.php +++ b/Classes/Core/Acceptance/Helper/AbstractPageTree.php @@ -52,7 +52,7 @@ public function openPath(array $path) foreach ($path as $pageName) { $context = $this->ensureTreeNodeIsOpen($pageName, $context); } - $context->findElement(\WebDriverBy::cssSelector(self::$treeItemAnchorSelector))->click(); + $context->findElement(WebDriverBy::cssSelector(self::$treeItemAnchorSelector))->click(); } /** @@ -65,7 +65,7 @@ public function getPageTreeElement() $I = $this->tester; $I->switchToIFrame(); return $I->executeInSelenium(function (\Facebook\WebDriver\Remote\RemoteWebDriver $webdriver) { - return $webdriver->findElement(\WebDriverBy::cssSelector(self::$pageTreeSelector)); + return $webdriver->findElement(WebDriverBy::cssSelector(self::$pageTreeSelector)); }); } @@ -84,11 +84,11 @@ protected function ensureTreeNodeIsOpen(string $nodeText, RemoteWebElement $cont /** @var RemoteWebElement $context */ $context = $I->executeInSelenium(function () use ($nodeText, $context ) { - return $context->findElement(\WebDriverBy::xpath('//*[text()=\'' . $nodeText . '\']/..')); + return $context->findElement(WebDriverBy::xpath('//*[text()=\'' . $nodeText . '\']/..')); }); try { - $context->findElement(\WebDriverBy::cssSelector('.chevron.collapsed'))->click(); + $context->findElement(WebDriverBy::cssSelector('.chevron.collapsed'))->click(); } catch (\Facebook\WebDriver\Exception\NoSuchElementException $e) { // element not found so it may be already opened... } catch (\Facebook\WebDriver\Exception\ElementNotVisibleException $e) { diff --git a/Classes/Core/Acceptance/Helper/AbstractSiteConfiguration.php b/Classes/Core/Acceptance/Helper/AbstractSiteConfiguration.php new file mode 100644 index 00000000..de1a8cf8 --- /dev/null +++ b/Classes/Core/Acceptance/Helper/AbstractSiteConfiguration.php @@ -0,0 +1,67 @@ + ['id' => 0, 'title' => 'English', 'locale' => 'en_US.UTF8'], + 'DK' => ['id' => 1, 'title' => 'Dansk', 'locale' => 'da_DK.UTF8'], + 'DE' => ['id' => 2, 'title' => 'German', 'locale' => 'de_DE.UTF8'], + ]; + + /** + * @var AcceptanceTester + * + * currently unused, but let's keep it for the time being. It will come in handy. + */ + protected $tester; + + public function adjustSiteConfiguration(): void + { + $sitesDir = ORIGINAL_ROOT . 'typo3temp/var/tests/acceptance/typo3conf/sites'; + $scandir = scandir($sitesDir); + if (!empty($scandir)) { + $identifer = end(array_diff($scandir, ['.', '..'])); + } else { + $identifer = 'local-testing'; + } + + $configuration = $this->buildSiteConfiguration(1, '/'); + $configuration += [ + 'langugages' => [ + $this->buildDefaultLanguageConfiguration('EN', '/en/'), + $this->buildLanguageConfiguration('DK', '/dk/'), + $this->buildLanguageConfiguration('DE', '/de/'), + ], + ]; + + $siteConfiguration = new SiteConfiguration($sitesDir); + $siteConfiguration->write($identifer, $configuration); + } +} diff --git a/Classes/Core/Acceptance/Helper/Acceptance.php b/Classes/Core/Acceptance/Helper/Acceptance.php index 51458932..a852a809 100644 --- a/Classes/Core/Acceptance/Helper/Acceptance.php +++ b/Classes/Core/Acceptance/Helper/Acceptance.php @@ -16,13 +16,23 @@ */ use Codeception\Module; +use Codeception\Module\WebDriver; use Codeception\Step; +use Codeception\TestInterface; /** * Helper class to verify javascript browser console does not throw errors. */ class Acceptance extends Module { + private $allowedBrowserErrors = []; + + public function _before(TestInterface $test) + { + parent::_before($test); + $this->allowedBrowserErrors = []; + } + /** * Wait for backend progress bar to finish / disappear before "click" steps are performed. * @@ -45,7 +55,39 @@ public function _beforeStep(Step $step) */ public function _afterStep(Step $step) { - $this->assertEmptyBrowserConsole(); + if ($this->isBrowserConsoleSupported()) { + $this->assertEmptyBrowserConsole(); + } + } + + /** + * Selenium's logging interface is not yet supported by geckodriver, so browser console tests are disabled + * at the moment. However, custom parsing of geckodriver's console output can be implemented. + * + * @return bool + * + * @see https://github.com/mozilla/geckodriver/issues/284 (issue) + * @see https://github.com/mozilla/geckodriver/issues/284#issuecomment-477677764 (custom implementation I) + * @see https://github.com/nightwatchjs/nightwatch/issues/2217#issuecomment-541139435 (custom implementation II) + */ + protected function isBrowserConsoleSupported() + { + return $this->getWebDriver()->_getConfig('browser') !== 'firefox'; + } + + public function allowBrowserError(string $pattern): void + { + $this->allowedBrowserErrors[] = $pattern; + } + + public function isAllowedBrowserError(string $message): bool + { + foreach ($this->allowedBrowserErrors as $pattern) { + if (preg_match($pattern, $message)) { + return true; + } + } + return false; } /** @@ -64,6 +106,7 @@ public function assertEmptyBrowserConsole() if (true === isset($logEntry['level']) && true === isset($logEntry['message']) && $this->isJSError($logEntry['level'], $logEntry['message']) + && $this->isAllowedBrowserError((string)$logEntry['message']) === false ) { // Timestamp is in milliseconds, but date() requires seconds. $time = date('H:i:s', (int)($logEntry['timestamp'] / 1000)); @@ -94,4 +137,12 @@ protected function isJSError($logEntryLevel, $message) { return $logEntryLevel === 'SEVERE' && strpos($message, 'ERR_PROXY_CONNECTION_FAILED') === false; } + + /** + * @return WebDriver + */ + protected function getWebDriver() + { + return $this->getModule('WebDriver'); + } } diff --git a/Classes/Core/Acceptance/Helper/Login.php b/Classes/Core/Acceptance/Helper/Login.php index 10b47e30..138c1e8a 100644 --- a/Classes/Core/Acceptance/Helper/Login.php +++ b/Classes/Core/Acceptance/Helper/Login.php @@ -17,9 +17,11 @@ use Codeception\Exception\ConfigurationException; use Codeception\Module; +use Codeception\Module\WebDriver; +use Codeception\Util\Locator; /** - * Helper class to log in backend users and load backend + * Helper class to log in backend users and load backend. */ class Login extends Module { @@ -31,46 +33,114 @@ class Login extends Module ]; /** - * Set a session cookie and load backend index.php + * Set a backend user session cookie and load the backend index.php. * - * @param string $role + * Use this action to change the backend user and avoid switching between users in the backend module + * "Backend Users" as this will change the user session ID and make it useless for subsequent calls of this action. + * + * @param string $role The backend user who should be logged in. * @throws ConfigurationException */ public function useExistingSession($role = '') { - $wd = $this->getModule('WebDriver'); - $wd->amOnPage('/typo3/index.php'); - - $sessionCookie = ''; - if ($role) { - if (!isset($this->config['sessions'][$role])) { - throw new ConfigurationException("Helper\Login doesn't have `sessions` defined for $role"); - } - $sessionCookie = $this->config['sessions'][$role]; + $webDriver = $this->getWebDriver(); + + $newUserSessionId = $this->getUserSessionIdByRole($role); + + $hasSession = $this->_loadSession(); + if ($hasSession && $newUserSessionId !== '' && $newUserSessionId !== $this->getUserSessionId()) { + $this->_deleteSession(); + $hasSession = false; } - // @todo: There is a bug in PhantomJS / firefox (?) where adding a cookie fails. - // This bug will be fixed in the next PhantomJS version but i also found - // this workaround. First reset / delete the cookie and than set it and catch - // the webdriver exception as the cookie has been set successful. - try { - $wd->resetCookie('be_typo_user'); - $wd->setCookie('be_typo_user', $sessionCookie); - } catch (\Facebook\WebDriver\Exception\UnableToSetCookieException $e) { + if (!$hasSession) { + $webDriver->amOnPage('/typo3/index.php'); + $webDriver->waitForElement('body[data-typo3-login-ready]'); + $this->_createSession($newUserSessionId); } - try { - $wd->resetCookie('be_lastLoginProvider'); - $wd->setCookie('be_lastLoginProvider', '1433416747'); - } catch (\Facebook\WebDriver\Exception\UnableToSetCookieException $e) { + + // Reload the page to have a logged in backend. + $webDriver->amOnPage('/typo3/index.php'); + + // Ensure main content frame is fully loaded, otherwise there are load-race-conditions .. + $webDriver->waitForElement('iframe[name="list_frame"]'); + $webDriver->switchToIFrame('list_frame'); + $webDriver->waitForElement(Locator::firstElement('div.module')); + // .. and switch back to main frame. + $webDriver->switchToIFrame(); + } + + /** + * @param string $role + * @return string + * @throws ConfigurationException + */ + protected function getUserSessionIdByRole($role) + { + if (empty($role)) { + return ''; + } + + if (!isset($this->_getConfig('sessions')[$role])) { + throw new ConfigurationException(sprintf( + 'Backend user session ID cannot be resolved for role "%s": ' . + 'Set session ID explicitly in configuration of module Login.', + $role + ), 1627554106); } - // reload the page to have a logged in backend - $wd->amOnPage('/typo3/index.php'); + return $this->_getConfig('sessions')[$role]; + } + + /** + * @return bool + */ + public function _loadSession() + { + return $this->getWebDriver()->loadSessionSnapshot('login'); + } + + public function _deleteSession() + { + $webDriver = $this->getWebDriver(); + $webDriver->resetCookie('be_typo_user'); + $webDriver->resetCookie('be_lastLoginProvider'); + $webDriver->deleteSessionSnapshot('login'); + } - // Ensure main content frame is fully loaded, otherwise there are load-race-conditions - $wd->switchToIFrame('list_frame'); - $wd->waitForText('Web Content Management System'); - // And switch back to main frame preparing a click to main module for the following main test case - $wd->switchToIFrame(); + /** + * Note: The "be_typo_user" and "be_lastLoginProvider" cookies may already be set in the bootstrapping process of + * the acceptance tests. They pose a problem because they are created with a different cookie path that still takes + * precedence over the cookies set in this method. The cookies that already exist are therefore deleted as a + * precaution. + * + * @param string $userSessionId + */ + public function _createSession($userSessionId) + { + $webDriver = $this->getWebDriver(); + $webDriver->resetCookie('be_typo_user'); + $webDriver->resetCookie('be_lastLoginProvider'); + $webDriver->setCookie('be_typo_user', $userSessionId); + $webDriver->setCookie('be_lastLoginProvider', '1433416747'); + $webDriver->saveSessionSnapshot('login'); + } + + /** + * @return string + */ + protected function getUserSessionId() + { + $userSessionId = $this->getWebDriver()->grabCookie('be_typo_user'); + return $userSessionId ?? ''; + } + + /** + * @return WebDriver + * @throws \Codeception\Exception\ModuleException + */ + protected function getWebDriver() + { + return $this->getModule('WebDriver'); } -} \ No newline at end of file +} diff --git a/Classes/Core/Functional/Framework/DataHandling/DataSet.php b/Classes/Core/Functional/Framework/DataHandling/DataSet.php index 5edb3e5e..52d2b903 100644 --- a/Classes/Core/Functional/Framework/DataHandling/DataSet.php +++ b/Classes/Core/Functional/Framework/DataHandling/DataSet.php @@ -199,6 +199,21 @@ public function getTableNames(): array return array_keys($this->data); } + /** + * @return int + */ + public function getMaximumPadding(): int + { + $maximums = array_map( + function (array $tableData) { + return count($tableData['fields'] ?? []); + }, + array_values($this->data) + ); + // adding additional index since field values are indented by one + return max($maximums) + 1; + } + /** * @param string $tableName * @return NULL|array @@ -240,8 +255,9 @@ public function getElements(string $tableName) /** * @param string $fileName + * @param null|int $padding */ - public function persist(string $fileName) + public function persist(string $fileName, int $padding = null) { $fileHandle = fopen($fileName, 'w'); @@ -253,15 +269,32 @@ public function persist(string $fileName) $fields = $tableData['fields']; array_unshift($fields, ''); - fputcsv($fileHandle, [$tableName]); - fputcsv($fileHandle, $fields); + fputcsv($fileHandle, $this->pad([$tableName], $padding)); + fputcsv($fileHandle, $this->pad($fields, $padding)); foreach ($tableData['elements'] as $element) { array_unshift($element, ''); - fputcsv($fileHandle, $element); + fputcsv($fileHandle, $this->pad($element, $padding)); } } fclose($fileHandle); } + + /** + * @param array $values + * @param null|int $padding + * @return array + */ + protected function pad(array $values, int $padding = null): array + { + if ($padding === null) { + return $values; + } + + return array_merge( + $values, + array_fill(0, $padding - count($values), '') + ); + } } diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php index e6fa07a0..778641d2 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php +++ b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerFactory.php @@ -41,6 +41,11 @@ class DataHandlerFactory */ private $dataMapPerWorkspace = []; + /** + * @var array + */ + private $commandMapPerWorkspace = []; + /** * @var bool[] */ @@ -82,6 +87,14 @@ public function getDataMapPerWorkspace(): array return $this->dataMapPerWorkspace; } + /** + * @return array + */ + public function getCommandMapPerWorkspace(): array + { + return $this->commandMapPerWorkspace; + } + /** * @return bool[] */ @@ -136,6 +149,9 @@ private function processEntityItem( $tableName = $entityConfiguration->getTableName(); $newId = StringUtility::getUniqueId('NEW'); $this->setInDataMap($tableName, $newId, $values, (int)$workspaceId); + if (isset($itemSettings['actions'])) { + $this->setInCommandMap($tableName, $newId, $nodeId, $itemSettings['actions'], (int)$workspaceId); + } foreach ($itemSettings['versionVariants'] ?? [] as $versionVariantSettings) { $this->processVersionVariantItem( @@ -199,7 +215,11 @@ private function processLanguageVariantItem( $tableName = $entityConfiguration->getTableName(); $newId = StringUtility::getUniqueId('NEW'); - $this->setInDataMap($tableName, $newId, $values, 0); + $workspaceId = $itemSettings['version']['workspace'] ?? 0; + $this->setInDataMap($tableName, $newId, $values, (int)$workspaceId); + if (isset($itemSettings['actions'])) { + $this->setInCommandMap($tableName, $newId, $nodeId, $itemSettings['actions'], (int)$workspaceId); + } foreach ($itemSettings['languageVariants'] ?? [] as $variantItemSettings) { $this->processLanguageVariantItem( @@ -230,6 +250,9 @@ private function processVersionVariantItem( $tableName = $entityConfiguration->getTableName(); $this->setInDataMap($tableName, $ancestorId, $values, (int)$values['workspace']); + if (isset($itemSettings['actions'])) { + $this->setInCommandMap($tableName, $ancestorId, $nodeId, $itemSettings['actions'], (int)$values['workspace']); + } } /** @@ -292,7 +315,14 @@ private function processEntityValues( $parentColumnName = $entityConfiguration->getParentColumnName(); $nodeColumnName = $entityConfiguration->getNodeColumnName(); - $suggestedId = $staticId > 0 ? $staticId : $this->incrementDynamicId($entityConfiguration); + // @todo probably dynamic assignment is a bad idea & we should just use auto incremented values... + $incrementValue = !empty($itemSettings['version']) ? 2 : 1; + if ($staticId > 0) { + $suggestedId = $staticId; + $this->incrementDynamicId($entityConfiguration, $incrementValue - 1); + } else { + $suggestedId = $this->incrementDynamicId($entityConfiguration, $incrementValue); + } $this->addSuggestedId($entityConfiguration, $suggestedId); $values = $entityConfiguration->processValues($itemSettings[$sourceProperty]); $values['uid'] = $suggestedId; @@ -352,6 +382,16 @@ private function addSuggestedId( int $suggestedId ): void { $identifier = $entityConfiguration->getTableName() . ':' . $suggestedId; + if (isset($this->suggestedIds[$identifier])) { + throw new \LogicException( + sprintf( + 'Cannot redeclare identifier "%s" with "%d"', + $identifier, + $suggestedId + ), + 1568146788 + ); + } $this->suggestedIds[$identifier] = true; } @@ -390,12 +430,16 @@ private function addStaticId( * @return int */ private function incrementDynamicId( - EntityConfiguration $entityConfiguration + EntityConfiguration $entityConfiguration, + int $incrementValue = 1 ): int { if (!isset($this->dynamicIdsPerEntity[$entityConfiguration->getName()])) { $this->dynamicIdsPerEntity[$entityConfiguration->getName()] = static::DYNAMIC_ID; } - return ++$this->dynamicIdsPerEntity[$entityConfiguration->getName()]; + $result = $this->dynamicIdsPerEntity[$entityConfiguration->getName()]; + // increment for next(!) assingment, since current process might create version or language variants + $this->dynamicIdsPerEntity[$entityConfiguration->getName()] += $incrementValue; + return $result; } /** @@ -439,6 +483,37 @@ private function setInDataMap( $this->dataMapPerWorkspace[$workspaceId][$tableName][$identifier] = $values; } + private function setInCommandMap( + string $tableName, + string $identifier, + ?string $nodeId, + array $actionItems, + int $workspaceId = 0 + ): void { + if (empty($actionItems)) { + return; + } + // @todo implement `immediate` actions -> needs to split dataMap & commandMap in logical sections + foreach ($actionItems as $actionItem) { + $action = $actionItem['action'] ?? null; + $type = $actionItem['type'] ?? null; + $target = $actionItem['target'] ?? null; + if ($action === 'move') { + if ($type === 'toPage' && $target !== null) { + $this->commandMapPerWorkspace[$workspaceId][$tableName][$identifier]['move'] = $target; + } elseif ($type === 'toTop' && $nodeId !== null) { + $this->commandMapPerWorkspace[$workspaceId][$tableName][$identifier]['move'] = $nodeId; + } elseif ($type === 'afterRecord' && $target !== null) { + $this->commandMapPerWorkspace[$workspaceId][$tableName][$identifier]['move'] = '-' . $target; + } + } elseif ($action === 'delete') { + $this->commandMapPerWorkspace[$workspaceId][$tableName][$identifier]['delete'] = true; + } elseif ($action === 'discard' && $workspaceId > 0) { + $this->commandMapPerWorkspace[$workspaceId][$tableName][$identifier]['clearWSID'] = true; + } + } + } + /** * @param int $workspaceId * @param string $tableName diff --git a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php index c193885f..ac9b46a0 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php +++ b/Classes/Core/Functional/Framework/DataHandling/Scenario/DataHandlerWriter.php @@ -80,6 +80,17 @@ public function invokeFactory(DataHandlerFactory $factory) $this->dataHandler->errorLog ); } + foreach ($factory->getCommandMapPerWorkspace() as $workspaceId => $commandMap) { + $commandMap = $this->updateCommandMap($commandMap); + $backendUser = clone $this->backendUser; + $backendUser->workspace = $workspaceId; + $this->dataHandler->start([], $commandMap, $backendUser); + $this->dataHandler->process_cmdmap(); + $this->errors = array_merge( + $this->errors, + $this->dataHandler->errorLog + ); + } } /** @@ -99,9 +110,22 @@ private function updateDataMap(array $dataMap): array $updatedTableDataMap = []; foreach ($dataMap as $tableName => $tableDataMap) { foreach ($tableDataMap as $key => $values) { - $pageId = $values['pid'] ?? null; $key = $this->dataHandler->substNEWwithIDs[$key] ?? $key; - $values['pid'] = $this->dataHandler->substNEWwithIDs[$pageId] ?? $pageId; + $values = array_map( + function ($value) { + if (!is_string($value)) { + return $value; + } + if (strpos($value, 'NEW') === 0) { + return $this->dataHandler->substNEWwithIDs[$value] ?? $value; + } + if (strpos($value, '-NEW') === 0) { + return $this->dataHandler->substNEWwithIDs[substr($value, 1)] ?? $value; + } + return $value; + }, + $values + ); if ((string)$key === (string)(int)$key) { unset($values['pid']); } @@ -110,4 +134,35 @@ private function updateDataMap(array $dataMap): array } return $updatedTableDataMap; } + + /** + * @param array $commandMap + * @return array + */ + private function updateCommandMap(array $commandMap): array + { + $updatedTableCommandMap = []; + foreach ($commandMap as $tableName => $tableDataMap) { + foreach ($tableDataMap as $key => $values) { + $key = $this->dataHandler->substNEWwithIDs[$key] ?? $key; + $values = array_map( + function ($value) { + if (!is_string($value)) { + return $value; + } + if (strpos($value, 'NEW') === 0) { + return $this->dataHandler->substNEWwithIDs[$value] ?? $value; + } + if (strpos($value, '-NEW') === 0) { + return $this->dataHandler->substNEWwithIDs[substr($value, 1)] ?? $value; + } + return $value; + }, + $values + ); + $updatedTableCommandMap[$tableName][$key] = $values; + } + } + return $updatedTableCommandMap; + } } diff --git a/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php b/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php index e613ad44..2bd93117 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php +++ b/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php @@ -16,8 +16,10 @@ */ use Doctrine\DBAL\FetchMode; +use Doctrine\DBAL\Query\QueryBuilder as DoctrineQueryBuilder; use Doctrine\DBAL\Schema\Table; -use TYPO3\CMS\Core\Database\Connection; +use Doctrine\DBAL\Connection; +use TYPO3\CMS\Core\Database\Query\QueryBuilder as TYPO3QueryBuilder; class DatabaseAccessor { @@ -90,7 +92,7 @@ public function import(array $import) */ private function exportTable(string $tableName): array { - return $this->connection->createQueryBuilder() + return $this->createQueryBuilder() ->select('*')->from($tableName) ->execute()->fetchAll(FetchMode::ASSOCIATIVE); } @@ -141,4 +143,16 @@ function (string $columnName) use ($table) { ); return array_combine($columnNames, $columnTypes); } + + /** + * @return DoctrineQueryBuilder|TYPO3QueryBuilder + */ + private function createQueryBuilder() + { + $queryBuilder = $this->connection->createQueryBuilder(); + if ($queryBuilder instanceof TYPO3QueryBuilder) { + $queryBuilder->getRestrictions()->removeAll(); + } + return $queryBuilder; + } } diff --git a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php index a9eabb7c..8217fa12 100644 --- a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php +++ b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php @@ -60,8 +60,7 @@ public function __construct(string $documentRoot, array $requestArguments = null private function initialize() { - $directory = dirname(realpath($this->documentRoot . '/index.php')); - $this->classLoader = require_once $directory . '/vendor/autoload.php'; + $this->classLoader = require_once __DIR__ . '/../../../../../../../autoload.php'; } /** @@ -160,6 +159,10 @@ public function executeAndOutput() \TYPO3\CMS\Core\Core\SystemEnvironmentBuilder::REQUESTTYPE_FE ); $container = \TYPO3\CMS\Core\Core\Bootstrap::init($this->classLoader); + if (\TYPO3\CMS\Core\Core\Environment::isComposerMode() && + \TYPO3\CMS\Core\Core\ClassLoadingInformation::isClassLoadingInformationAvailable()) { + \TYPO3\CMS\Core\Core\ClassLoadingInformation::registerClassLoadingInformation(); + } ArrayUtility::mergeRecursiveWithOverrule( $GLOBALS, $this->context->getGlobalSettings() ?? [] diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index 60437353..0c061e33 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -377,6 +377,18 @@ protected function setUpBackendUserFromFixture($userUid) { $this->importDataSet($this->backendUserFixture); + return $this->setUpBackendUser($userUid); + } + + /** + * Sets up Backend User which is already available in db + * + * @param int $userUid + * @return BackendUserAuthentication + * @throws Exception + */ + protected function setUpBackendUser($userUid): BackendUserAuthentication + { $queryBuilder = $this->getConnectionPool() ->getQueryBuilderForTable('be_users'); $queryBuilder->getRestrictions()->removeAll(); diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index a7b77859..61fa83bd 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -18,6 +18,7 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Platforms\MySqlPlatform; use Doctrine\DBAL\Platforms\PostgreSqlPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Platforms\SQLServerPlatform; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\ClassLoadingInformation; @@ -795,7 +796,8 @@ public function importXmlDatabaseFixture($path) */ public static function resetTableSequences(Connection $connection, string $tableName) { - if ($connection->getDatabasePlatform() instanceof PostgreSqlPlatform) { + $platform = $connection->getDatabasePlatform(); + if ($platform instanceof PostgreSqlPlatform) { $queryBuilder = $connection->createQueryBuilder(); $queryBuilder->getRestrictions()->removeAll(); $row = $queryBuilder->select('PGT.schemaname', 'S.relname', 'C.attname', 'T.relname AS tablename') @@ -827,6 +829,14 @@ public static function resetTableSequences(Connection $connection, string $table ) ); } + } elseif ($platform instanceof SqlitePlatform) { + // Drop eventually existing sqlite sequence for this table + $connection->exec( + sprintf( + 'DELETE FROM sqlite_sequence WHERE name=%s', + $connection->quote($tableName) + ) + ); } } diff --git a/README.md b/README.md index 3b1eac47..ade9457c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -[![Build Status](https://travis-ci.org/TYPO3/testing-framework.svg?branch=master)](https://travis-ci.org/TYPO3/testing-framework) - # TYPO3 testing framework for core and extensions A straight and slim set of classes and configuration to test TYPO3 extensions. This framework is diff --git a/Resources/Core/Build/Scripts/splitAcceptanceTests.php b/Resources/Core/Build/Scripts/splitAcceptanceTests.php index 9128ccdf..888ae2d2 100755 --- a/Resources/Core/Build/Scripts/splitAcceptanceTests.php +++ b/Resources/Core/Build/Scripts/splitAcceptanceTests.php @@ -82,6 +82,7 @@ public function execute() ->files() ->in(__DIR__ . '/../../../../../../../typo3/sysext/core/Tests/Acceptance/Backend') ->name('/Cest\.php$/') + ->sortByName() ; $parser = (new ParserFactory)->create(ParserFactory::ONLY_PHP7); diff --git a/Resources/Core/Build/Scripts/splitFunctionalTests.php b/Resources/Core/Build/Scripts/splitFunctionalTests.php index 576d3ac4..bc999a3c 100755 --- a/Resources/Core/Build/Scripts/splitFunctionalTests.php +++ b/Resources/Core/Build/Scripts/splitFunctionalTests.php @@ -82,6 +82,7 @@ public function execute() ->files() ->in(__DIR__ . '/../../../../../../../typo3/sysext/*/Tests/Functional') ->name('/Test\.php$/') + ->sortByName() ; $parser = (new ParserFactory)->create(ParserFactory::ONLY_PHP7); diff --git a/composer.json b/composer.json index 919d54a3..9c0efcda 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "issues": "https://github.com/TYPO3/testing-framework/issues" }, "require": { - "phpunit/phpunit": "^7.1", - "mikey179/vfsStream": "~1.6.0", + "phpunit/phpunit": "^7.5.6", + "mikey179/vfsstream": "~1.6.8", "typo3fluid/fluid": "^2.5", "typo3/cms-core": "^9.3", "typo3/cms-backend": "^9.3",