diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e45f30f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +Tests export-ignore +patches export-ignore +.github export-ignore + +.gitattributes export-ignore +.gitignore export-ignore + +.php-cs-fixer.dist.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore +codeception.dist.yml export-ignore + +shell.nix export-ignore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e5b707b..5c242de 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,23 +1,20 @@ name: CI + on: - pull_request + jobs: check-composer: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Install Composer - uses: shivammathur/setup-php@v2 + - uses: cachix/install-nix-action@v17 with: - php-version: 8.1 - coverage: none - tools: composer:v2 - env: - COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + nix_path: nixpkgs=channel:nixos-unstable - name: Validate composer.json - run: composer validate + run: nix-shell --pure --run project-validate-composer php-linting: runs-on: ubuntu-latest @@ -31,62 +28,42 @@ jobs: - name: Checkout uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + - name: PHP lint run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l" xml-linting: runs-on: ubuntu-latest - needs: [check-composer] steps: - uses: actions/checkout@v2 - - name: Install PHP - uses: shivammathur/setup-php@v2 + - uses: cachix/install-nix-action@v17 with: - php-version: "8.1" - coverage: none - tools: composer:v2 + nix_path: nixpkgs=channel:nixos-unstable - - name: Install xmllint - run: sudo apt-get install libxml2-utils - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest - - - name: PHPUnit configuration file - run: xmllint --schema vendor/phpunit/phpunit/phpunit.xsd --noout phpunit.xml.dist - - - name: Fetch schema for xliff - run: wget https://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd --output-document=xliff-core-1.2-strict.xsd - - - name: TYPO3 language files - run: xmllint --schema xliff-core-1.2-strict.xsd --noout $(find Resources -name '*.xlf') + - name: Validate XML + run: nix-shell --pure --run project-validate-xml coding-guideline: runs-on: ubuntu-latest - needs: - - php-linting - - xml-linting steps: - uses: actions/checkout@v2 - - name: Install PHP - uses: shivammathur/setup-php@v2 + - uses: cachix/install-nix-action@v17 with: - php-version: "8.1" - coverage: none - tools: composer:v2 + nix_path: nixpkgs=channel:nixos-unstable - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest - - - name: Coding Guideline - run: ./vendor/bin/php-cs-fixer fix --dry-run --diff + - name: Check Coding Guideline + run: nix-shell --pure --run project-coding-guideline code-quality: runs-on: ubuntu-latest - needs: - - php-linting strategy: matrix: include: @@ -103,17 +80,16 @@ jobs: coverage: none tools: composer:v2 - - name: Install dependencies with expected TYPO3 version - run: composer require --no-interaction --prefer-dist --no-progress + - name: Install dependencies + run: |- + composer require --no-interaction --prefer-dist --no-progress + ./vendor/bin/codecept build - name: Code Quality (by PHPStan) run: ./vendor/bin/phpstan analyse tests-mysql: runs-on: ubuntu-latest - needs: - - php-linting - - xml-linting strategy: matrix: include: @@ -137,14 +113,32 @@ jobs: mysql database: 'typo3' mysql root password: 'root' + - name: Wait for MySQL + run: | + while ! mysqladmin ping --host=127.0.0.1 --password=root --silent; do + sleep 1 + done + - name: Install dependencies - run: composer require --no-interaction --prefer-dist --no-progress + run: composer install --no-interaction --prefer-dist --no-progress - name: PHPUnit Tests - run: |- - export typo3DatabaseDriver="pdo_mysql" - export typo3DatabaseName="typo3" - export typo3DatabaseHost="127.0.0.1" - export typo3DatabaseUsername="root" - export typo3DatabasePassword="root" - ./vendor/bin/phpunit --testdox + env: + typo3DatabaseDriver: "pdo_mysql" + typo3DatabaseName: "typo3" + typo3DatabaseHost: "127.0.0.1" + typo3DatabaseUsername: "root" + typo3DatabasePassword: "root" + run: ./vendor/bin/phpunit --testdox + + tests-acceptance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: cachix/install-nix-action@v17 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Run Acceptance Tests + run: nix-shell --pure --run project-test-acceptance diff --git a/.gitignore b/.gitignore index eb00166..a7e2bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /composer.lock /.php-cs-fixer.cache +/.Build/ /vendor/ -/public/ -/var/ diff --git a/Classes/Middleware/CookieSessionMiddleware.php b/Classes/Middleware/CookieSessionMiddleware.php new file mode 100644 index 0000000..f593f02 --- /dev/null +++ b/Classes/Middleware/CookieSessionMiddleware.php @@ -0,0 +1,112 @@ + + * + * 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 WerkraumMedia\Watchlist\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Component\HttpFoundation\Cookie; +use TYPO3\CMS\Core\Http\NormalizedParams; +use WerkraumMedia\Watchlist\Session\CookieSessionService; + +class CookieSessionMiddleware implements MiddlewareInterface +{ + private CookieSessionService $cookieSession; + + public function __construct( + CookieSessionService $cookieSession + ) { + $this->cookieSession = $cookieSession; + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + $response = $handler->handle($request); + + if ($this->shouldAddCookie($request)) { + return $this->addCookie($response, $request); + } + if ($this->shouldRemoveCookie($request)) { + return $this->removeCookie($response, $request); + } + + return $response; + } + + private function shouldAddCookie(ServerRequestInterface $request): bool + { + return $this->cookieSession->getCookieValue() !== ''; + } + + private function addCookie( + ResponseInterface $response, + ServerRequestInterface $request + ): ResponseInterface { + return $response->withAddedHeader( + 'Set-Cookie', + $this->getCookie($request)->__toString() + ); + } + + private function shouldRemoveCookie(ServerRequestInterface $request): bool + { + $cookieName = $this->cookieSession->getCookieName(); + + return $this->cookieSession->getCookieValue() === '' + && isset($request->getCookieParams()[$cookieName]) + ; + } + + private function removeCookie( + ResponseInterface $response, + ServerRequestInterface $request + ): ResponseInterface { + $cookie = $this->getCookie($request) + ->withExpires(-1); + return $response->withAddedHeader('Set-Cookie', $cookie->__toString()); + } + + private function getCookie(ServerRequestInterface $request): Cookie + { + $normalizedParams = $request->getAttribute('normalizedParams'); + if (!$normalizedParams instanceof NormalizedParams) { + throw new \Exception('Could not retrieve normalized params from request.', 1664357339); + } + + return new Cookie( + $this->cookieSession->getCookieName(), + $this->cookieSession->getCookieValue(), + $GLOBALS['EXEC_TIME'] + 7776000, // 90 days + $normalizedParams->getSitePath() . TYPO3_mainDir, + '', + false, + false, + false, + Cookie::SAMESITE_STRICT + ); + } +} diff --git a/Classes/Session/Typo3FrontendSessionService.php b/Classes/Session/CookieSessionService.php similarity index 61% rename from Classes/Session/Typo3FrontendSessionService.php rename to Classes/Session/CookieSessionService.php index f67e114..d687ad1 100644 --- a/Classes/Session/Typo3FrontendSessionService.php +++ b/Classes/Session/CookieSessionService.php @@ -23,25 +23,33 @@ declare(strict_types=1); namespace WerkraumMedia\Watchlist\Session; +use TYPO3\CMS\Core\Http\ServerRequest; use TYPO3\CMS\Extbase\Property\PropertyMapper; -use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; use WerkraumMedia\Watchlist\Domain\Model\Item; use WerkraumMedia\Watchlist\Domain\Model\Watchlist; -class Typo3FrontendSessionService implements SessionServiceInterface +class CookieSessionService implements SessionServiceInterface { private PropertyMapper $propertyMapper; - public function __construct( - PropertyMapper $propertyMapper - ) { + private array $watchlists = []; + + // Seems to be a bug leading to different instances if we use constructor. + public function injectPropertyMapper(PropertyMapper $propertyMapper): void + { $this->propertyMapper = $propertyMapper; } public function getWatchlist(string $identifier): ?Watchlist { - $items = $this->getTsfe()->fe_user->getSessionData('watchlist-' . $identifier) ?: []; - if ($items === [] || is_array($items) === false) { + $cookieName = $this->getCookieName(); + $cookie = $this->getRequest()->getCookieParams()[$cookieName] ?? ''; + $items = array_filter(explode( + ',', + $this->getRequest()->getCookieParams()['watchlist'] ?? '' + )); + + if ($items === []) { return null; } @@ -61,13 +69,24 @@ class Typo3FrontendSessionService implements SessionServiceInterface public function update(Watchlist $watchlist): void { - $this->getTsfe()->fe_user->setAndSaveSessionData( - 'watchlist-' . $watchlist->getIdentifier(), - array_map(fn (Item $item) => $item->getUniqueIdentifier(), $watchlist->getItems()) + $this->watchlists[$watchlist->getIdentifier()] = array_map( + fn (Item $item) => $item->getUniqueIdentifier(), + $watchlist->getItems() ); } - private function getTsfe(): TypoScriptFrontendController + + public function getCookieName(): string { - return $GLOBALS['TSFE']; + return 'watchlist'; + } + + public function getCookieValue(): string + { + return implode(',', $this->watchlists['default'] ?? []); + } + + private function getRequest(): ServerRequest + { + return $GLOBALS['TYPO3_REQUEST']; } } diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..a41af5c --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,36 @@ + + * + * 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. + */ + +return [ + 'frontend' => [ + 'watchlist-cookiesession' => [ + 'target' => \WerkraumMedia\Watchlist\Middleware\CookieSessionMiddleware::class, + 'after' => [ + 'typo3/cms-frontend/content-length-headers', + ], + 'before' => [ + 'typo3/cms-frontend/output-compression', + ], + ], + ], +]; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 4ad187a..2a0a67c 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -11,3 +11,7 @@ services: WerkraumMedia\Watchlist\Domain\Items\Page\ItemHandler: tags: ['watchlist.itemHandler'] + + WerkraumMedia\Watchlist\Session\CookieSessionService: + # Has state and needs to be shared in order to provide this state to middleware + shared: true diff --git a/README.rst b/README.rst index 5ff239f..ebc5ede 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,7 @@ Feature set =========== The extension provides an controller with actions to add and remove items from watchlist. +Each item can only exist once within the watchlist. There is also an action to show the current watchlist. @@ -21,11 +22,19 @@ The extension ships with support for items of type ``pages``, but this is more a demonstration an used for testing. Projects are way too different and should provide their own items, see "Custom Items". +The extension also provides a JavaScript which will parse data-Attributes of DOM and +attach listener to add elements to the watchlist. + Custom Items ============ A developer needs to implement an ``ItemHandler`` and an class representing the ``Item``. +The used identifier has to be unique throughout the system. The first part is the +item type which is returned by the ItemHandler to be handled, followed by a minus and +the rest of the identifier, leading to: ``-rest-of-identifier``. +Commas should not be used within the identifier. + ``ItemHandler`` --------------- @@ -51,3 +60,45 @@ The Handler needs to be registered via Symfony Tags, e.g. via ``Services.yaml``: -------- The class needs to implement the ``WerkraumMedia\Watchlist\Domain\Model\Item``. + +JavaScript +========== + +The JavaScript respects two ``data-`` attributes: + +``data-watchlist-counter`` + Defines that this element will hold the current number of items on watch list. + The JavaScript will update the content of the element. + + .. code:: html + + + +``data-watchlist-item`` + Defines that this element represents an actual item. + The attribute needs the identifier of the item as value, e.g.: ``data-watchlist-item="page-1"`` + + An EventListener will be added listening for click events on that element. + Each click will toggle the state of the item on the watch list. + + The JavaScript will add a CSS class to the element, depending on the state of the item. + ``watchlist-inactive`` will be added in case the item is not on the watchlist. + ``watchlist-active`` will be added in case the item is on the watchlist. + +A custom Event will be triggered whenever an item is added or removed from the watchlist. +The event is triggered on the ``document``. +The event provides the identifier of the item. +An example listener can look like: + +.. code:: js + + document.addEventListener('WatchlistUpdate', function(event) { + console.log(event.detail.watchlistItem); + }); + +Example +------- + +A concrete example can be found within ``Tests/Fixtures/FrontendRendering.typoscript``. +This example includes the provided JavaScript file as well as some custom CSS and Markup. +The content element is not necessary. diff --git a/Resources/Public/JavaScript/Watchlist.js b/Resources/Public/JavaScript/Watchlist.js new file mode 100644 index 0000000..2b41b06 --- /dev/null +++ b/Resources/Public/JavaScript/Watchlist.js @@ -0,0 +1,112 @@ +(function () { + + // From: https://plainjs.com/javascript/utilities/set-cookie-get-cookie-and-delete-cookie-5/ + const cookie = { + name: 'watchlist', + days: 7, + get: function() { + let v = document.cookie.match('(^|;) ?' + cookie.name + '=([^;]*)(;|$)'); + return v ? v[2] : null; + }, + set: function(value) { + let d = new Date; + d.setTime(d.getTime() + 24 * 60 * 60 * 1000 * cookie.days); + document.cookie = cookie.name + "=" + value + ";path=/;expires=" + d.toGMTString(); + }, + delete: function() { + let d = new Date; + d.setTime(d.getTime() -1); + document.cookie = cookie.name + "=;path=/;expires=" + d.toGMTString(); + } + }; + + const watchlist = { + get: function() { + const cookieValue = cookie.get(); + if (cookieValue === null) { + return []; + } + + return cookieValue.split(','); + }, + save: function(items) { + var cookieValue = items.join(','); + + if (cookieValue == '') { + cookie.delete(); + } else { + cookie.set(items.join(',')); + } + }, + toggleItem: function(identifier) { + let items = watchlist.get(); + const position = items.indexOf(identifier); + + if (position === -1) { + items = items.concat(identifier); + } else { + items.splice(position, 1); + } + + watchlist.save(items); + watchlist.triggerUpdateEvent(identifier); + }, + triggerUpdateEvent: function(identifier) { + const event = new CustomEvent('WatchlistUpdate', { + detail: { + watchlistItem: identifier + } + }); + document.dispatchEvent(event); + } + }; + + const watchlistHtml = { + selectors: { + items: '[data-watchlist-item]', + counter: '[data-watchlist-counter]' + }, + getItems: function() { + return document.querySelectorAll(watchlistHtml.selectors.items); + }, + getCounters: function() { + return document.querySelectorAll(watchlistHtml.selectors.counter); + }, + update: function() { + watchlistHtml.updateCounter(); + watchlistHtml.updateItems(); + }, + updateCounter: function() { + const count = watchlist.get().length; + watchlistHtml.getCounters().forEach(function (element) { + element.innerText = count; + }); + }, + updateItems: function() { + const items = watchlist.get(); + watchlistHtml.getItems().forEach(function (element) { + if (items.indexOf(element.dataset.watchlistItem) === -1) { + element.classList.add('watchlist-inactive'); + element.classList.remove('watchlist-active'); + return; + } + + element.classList.remove('watchlist-inactive'); + element.classList.add('watchlist-active'); + }); + } + }; + + document.addEventListener('DOMContentLoaded', function() { + watchlistHtml.update(); + + watchlistHtml.getItems().forEach(function (element) { + element.addEventListener('click', function(event) { + watchlist.toggleItem(event.currentTarget.dataset.watchlistItem); + }); + }); + }); + document.addEventListener('WatchlistUpdate', function(event) { + watchlistHtml.update(); + }); +})(); diff --git a/Tests/Acceptance/Data/.gitkeep b/Tests/Acceptance/Data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Acceptance/JavaScriptCest.php b/Tests/Acceptance/JavaScriptCest.php new file mode 100644 index 0000000..bb19245 --- /dev/null +++ b/Tests/Acceptance/JavaScriptCest.php @@ -0,0 +1,95 @@ + + * + * 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 WerkraumMedia\Watchlist\Tests\Acceptance; + +use WerkraumMedia\Watchlist\Tests\Acceptance\Support\AcceptanceTester; + +class JavaScriptCest +{ + private const SELECTOR_COUNTER = '.watchlist-badge-counter'; + + public function counterShowsZeroByDefault(AcceptanceTester $I): void + { + $I->amOnPage('/'); + $I->see('Watchlist is empty'); + $I->see('0', self::SELECTOR_COUNTER); + } + + public function canAddItem(AcceptanceTester $I): void + { + $I->amOnPage('/'); + $I->see('Watchlist is empty'); + + $I->see('Page 1 zur Merkliste hinzufügen'); + $I->dontSee('Page 1 von Merkliste entfernen'); + $I->click('button[data-watchlist-item="page-1"]'); + + $I->dontSee('Page 1 zur Merkliste hinzufügen'); + $I->see('Page 1 von Merkliste entfernen'); + $I->see('1', self::SELECTOR_COUNTER); + } + + public function canRemoveItem(AcceptanceTester $I): void + { + $this->canAddItem($I); + + $I->click('button[data-watchlist-item="page-1"]'); + + $I->see('Page 1 zur Merkliste hinzufügen'); + $I->dontSee('Page 1 von Merkliste entfernen'); + $I->see('0', self::SELECTOR_COUNTER); + } + + public function canAddTwoItems(AcceptanceTester $I): void + { + $this->canAddItem($I); + + $I->click('button[data-watchlist-item="page-2"]'); + $I->dontSee('Page 2 zur Merkliste hinzufügen'); + $I->see('Page 2 von Merkliste entfernen'); + $I->see('2', self::SELECTOR_COUNTER); + } + + public function canRemoveFirstItem(AcceptanceTester $I): void + { + $this->canAddTwoItems($I); + + $I->click('button[data-watchlist-item="page-1"]'); + $I->see('Page 1 zur Merkliste hinzufügen'); + $I->see('Page 2 von Merkliste entfernen'); + $I->dontSee('Page 2 zur Merkliste hinzufügen'); + $I->see('1', self::SELECTOR_COUNTER); + } + + public function keepsStateOnReload(AcceptanceTester $I): void + { + $this->canAddItem($I); + + $I->amOnPage('/'); + + $I->dontSee('Page 1 zur Merkliste hinzufügen'); + $I->see('Page 1 von Merkliste entfernen'); + $I->see('1', self::SELECTOR_COUNTER); + } +} diff --git a/Tests/Acceptance/Support/AcceptanceTester.php b/Tests/Acceptance/Support/AcceptanceTester.php new file mode 100644 index 0000000..1a4b080 --- /dev/null +++ b/Tests/Acceptance/Support/AcceptanceTester.php @@ -0,0 +1,51 @@ + + * + * 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 WerkraumMedia\Watchlist\Tests\Acceptance\Support; + +use Codeception\Actor; +use WerkraumMedia\Watchlist\Tests\Acceptance\Support\_generated\AcceptanceTesterActions; + +/** + * Inherited Methods + * @method void wantToTest($text) + * @method void wantTo($text) + * @method void execute($callable) + * @method void expectTo($prediction) + * @method void expect($prediction) + * @method void amGoingTo($argumentation) + * @method void am($role) + * @method void lookForwardTo($achieveValue) + * @method void comment($description) + * @method void pause() + * + * @SuppressWarnings(PHPMD) +*/ +class AcceptanceTester extends Actor +{ + use AcceptanceTesterActions; + + /** + * Define custom actions here + */ +} diff --git a/Tests/Acceptance/Support/Environment.php b/Tests/Acceptance/Support/Environment.php new file mode 100644 index 0000000..65abb36 --- /dev/null +++ b/Tests/Acceptance/Support/Environment.php @@ -0,0 +1,61 @@ + + * + * 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 WerkraumMedia\Watchlist\Tests\Acceptance\Support; + +use TYPO3\TestingFramework\Core\Acceptance\Extension\BackendEnvironment; + +/** + * Load various core extensions and styleguide and call styleguide generator + */ +class Environment extends BackendEnvironment +{ + /** + * Load a list of core extensions and styleguide + * + * @var array + */ + protected $localConfig = [ + 'coreExtensionsToLoad' => [ + 'install', + 'core', + 'backend', + 'extbase', + 'frontend', + 'fluid', + 'fluid_styled_content', + ], + 'testExtensionsToLoad' => [ + 'typo3conf/ext/watchlist', + ], + 'csvDatabaseFixtures' => [ + __DIR__ . '/../../Fixtures/BasicDatabase.csv', + ], + 'additionalFoldersToCreate' => [ + 'config', + ], + 'pathsToLinkInTestInstance' => [ + 'typo3conf/ext/watchlist/Tests/Fixtures/Sites/' => 'config/sites', + ], + ]; +} diff --git a/Tests/Acceptance/Support/_generated/.gitignore b/Tests/Acceptance/Support/_generated/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/Tests/Acceptance/Support/_generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/Tests/Acceptance/_output/.gitignore b/Tests/Acceptance/_output/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/Tests/Acceptance/_output/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/Tests/Fixtures/BasicDatabase.csv b/Tests/Fixtures/BasicDatabase.csv new file mode 100644 index 0000000..5048634 --- /dev/null +++ b/Tests/Fixtures/BasicDatabase.csv @@ -0,0 +1,8 @@ +"pages",,,,,, +,"uid","pid","slug","title",, +,1,0,"/","Page Title",, +,2,1,"/page-2","Page 2 Title",, +"sys_template",,,,,, +,"uid","pid","root","clear","constants","config" +,1,1,1,3,"databasePlatform = mysql"," +" diff --git a/Tests/Fixtures/FrontendRendering.typoscript b/Tests/Fixtures/FrontendRendering.typoscript new file mode 100644 index 0000000..64cf101 --- /dev/null +++ b/Tests/Fixtures/FrontendRendering.typoscript @@ -0,0 +1,35 @@ +page = PAGE +page { + # Include JavaScript in order to test the JavaScript + includeJSFooter.watchlist = EXT:watchlist/Resources/Public/JavaScript/Watchlist.js + + # Render the content element + 10 =< tt_content.watchlist_watchlist.20 + + # Add minimum CSS for checking functionality within Acceptance tests + cssInline { + 10 = TEXT + 10.value ( + .watchlist-inactive .remove-from-list { display: none; } + .watchlist-active .add-to-list { display: none; } + ) + } + + # Add some example markup for Acceptance Tests + 20 = TEXT + 20.value ( + + + + + + ) +} diff --git a/Tests/Functional/Frontend/Fixtures/Sites/default/config.yaml b/Tests/Fixtures/Sites/default/config.yaml similarity index 100% rename from Tests/Functional/Frontend/Fixtures/Sites/default/config.yaml rename to Tests/Fixtures/Sites/default/config.yaml diff --git a/Tests/Functional/Frontend/BasicsTest.php b/Tests/Functional/BasicsTest.php similarity index 73% rename from Tests/Functional/Frontend/BasicsTest.php rename to Tests/Functional/BasicsTest.php index 6757b71..0820101 100644 --- a/Tests/Functional/Frontend/BasicsTest.php +++ b/Tests/Functional/BasicsTest.php @@ -21,8 +21,9 @@ declare(strict_types=1); * 02110-1301, USA. */ -namespace WerkraumMedia\Watchlist\Tests\Functional\Frontend; +namespace WerkraumMedia\Watchlist\Tests\Functional; +use Symfony\Component\HttpFoundation\Cookie; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; @@ -40,21 +41,15 @@ class BasicsTest extends FunctionalTestCase 'typo3conf/ext/watchlist', ]; - protected $pathsToProvideInTestInstance = [ - 'typo3conf/ext/watchlist/Tests/Functional/Frontend/Fixtures/Sites/' => 'typo3conf/sites/', + protected $pathsToLinkInTestInstance = [ + 'typo3conf/ext/watchlist/Tests/Fixtures/Sites' => 'typo3conf/sites', ]; protected function setUp(): void { parent::setUp(); - $this->setUpBackendUserFromFixture(1); - - $this->importCSVDataSet(__DIR__ . '/Fixtures/BasicDatabase.csv'); - $this->setUpFrontendRootPage(1, [ - 'EXT:watchlist/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript', - 'EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript', - ]); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/BasicDatabase.csv'); } /** @@ -83,14 +78,14 @@ class BasicsTest extends FunctionalTestCase $result = $this->executeFrontendRequest($request); self::assertIsRedirect('http://localhost/?id=1', $result); - self::assertHasCookie('fe_typo_user', $result); + self::assertCookie('page-1', $this->getCookie($result)); $request = new InternalRequest(); $request = $request->withPageId(1); - $request = $request->withHeader('Cookie', self::getCookies($result)); + $request = $request->withHeader('Cookie', 'watchlist=page-1'); $result = $this->executeFrontendRequest($request); - self::assertStringContainsString('Page Title', $result->getBody()->__toString()); + self::assertStringContainsString('
  • Page Title
  • ', $result->getBody()->__toString()); } /** @@ -105,10 +100,10 @@ class BasicsTest extends FunctionalTestCase $request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1'); $result = $this->executeFrontendRequest($request); - $cookies = self::getCookies($result); + self::assertCookie('page-1', $this->getCookie($result)); $request = new InternalRequest(); - $request = $request->withHeader('Cookie', $cookies); + $request = $request->withHeader('Cookie', 'watchlist=page-1'); $request = $request->withPageId(1); $request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString()); $request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'remove'); @@ -116,9 +111,11 @@ class BasicsTest extends FunctionalTestCase $result = $this->executeFrontendRequest($request); self::assertIsRedirect('http://localhost/?id=1', $result); + $cookie = $this->getCookie($result); + self::assertInstanceOf(Cookie::class, $cookie); + self::assertLessThan(time(), $cookie->getExpiresTime()); $request = new InternalRequest(); - $request = $request->withHeader('Cookie', $cookies); $request = $request->withPageId(1); $result = $this->executeFrontendRequest($request); @@ -132,13 +129,24 @@ class BasicsTest extends FunctionalTestCase self::assertSame($redirectLocation, $result->getHeader('location')[0] ?? ''); } - private static function assertHasCookie(string $cookieName, InternalResponse $result): void + private static function assertCookie(string $value, ?Cookie $cookie): void { - self::assertStringContainsString($cookieName . '=', self::getCookies($result)); + self::assertInstanceOf(Cookie::class, $cookie); + self::assertSame('watchlist', $cookie->getName()); + self::assertSame('page-1', $cookie->getValue()); + self::assertNull($cookie->getDomain()); + self::assertSame('/typo3/', $cookie->getPath()); + self::assertSame('strict', $cookie->getSameSite()); + self::assertFalse($cookie->isSecure()); } - private static function getCookies(InternalResponse $result): string + private function getCookie(InternalResponse $result): ?Cookie { - return explode(' ', $result->getHeader('Set-Cookie')[0] ?? '', 2)[0] ?? ''; + $cookie = $result->getHeader('Set-Cookie')[0] ?? ''; + if ($cookie === '') { + return null; + } + + return Cookie::fromString($cookie); } } diff --git a/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv b/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv deleted file mode 100644 index 29aa1a9..0000000 --- a/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv +++ /dev/null @@ -1,3 +0,0 @@ -pages -,uid,pid,slug,title -,1,0,/,Page Title diff --git a/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript b/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript deleted file mode 100644 index c94ef7e..0000000 --- a/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript +++ /dev/null @@ -1,2 +0,0 @@ -page = PAGE -page.10 =< tt_content.watchlist_watchlist.20 diff --git a/codeception.dist.yml b/codeception.dist.yml new file mode 100644 index 0000000..c947baf --- /dev/null +++ b/codeception.dist.yml @@ -0,0 +1,33 @@ +namespace: WerkraumMedia\Watchlist\Tests\Acceptance\Support + +paths: + tests: Tests/Acceptance + data: Tests/Acceptance/Data + output: .Build/web/typo3temp/var/tests/AcceptanceReports + support: Tests/Acceptance/Support + +extensions: + enabled: + - Codeception\Extension\RunFailed + +suites: + acceptance: + actor: AcceptanceTester + path: . + extensions: + enabled: + - WerkraumMedia\Watchlist\Tests\Acceptance\Support\Environment: + typo3DatabaseUsername: 'testing' + typo3DatabasePassword: 'testing' + + modules: + enabled: + - WebDriver: + url: 'http://localhost:8080' + browser: 'firefox' + restart: true + path: '' + capabilities: + moz:firefoxOptions: + args: + - '-headless' diff --git a/composer.json b/composer.json index 9b9ff76..e8aa6bb 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,8 @@ "typo3/cms-frontend": "^11.5" }, "require-dev": { + "codeception/codeception": "^4.2", + "codeception/module-webdriver": "^2.0", "cweagans/composer-patches": "^1.7", "friendsofphp/php-cs-fixer": "^3.11", "phpstan/extension-installer": "^1.1", @@ -59,22 +61,20 @@ }, "extra": { "typo3/cms": { - "cms-package-dir": "{$vendor-dir}/typo3/cms", + "app-dir": ".Build", "extension-key": "watchlist", - "web-dir": "public" - } - }, - "scripts": { - "post-autoload-dump": [ - "@php -r 'is_dir($extFolder=__DIR__.\"/public/typo3conf/ext/\") || mkdir($extFolder, 0777, true);'", - "@php -r 'file_exists($extFolder=__DIR__.\"/public/typo3conf/ext/watchlist\") || symlink(__DIR__,$extFolder);'" - ] - }, - "extra": { + "web-dir": ".Build/web" + }, "patches": { "typo3/testing-framework": { "Support cookies in request": "patches/testing-framework-cookies.patch" } } + }, + "scripts": { + "post-autoload-dump": [ + "@php -r 'is_dir($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/\") || mkdir($extFolder, 0777, true);'", + "@php -r 'file_exists($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/watchlist\") || symlink(__DIR__,$extFolder);'" + ] } } diff --git a/phpstan.neon b/phpstan.neon index 7115c54..8f5d2ed 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,6 +4,8 @@ parameters: - Classes - Configuration - Tests + excludePaths: + - Tests/Acceptance/Support/_generated/ checkMissingIterableValueType: false reportUnmatchedIgnoredErrors: true checkGenericClassInNonGenericObjectType: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0acc317..858b92f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,27 +1,21 @@ - - - - - Classes - - + > @@ -32,6 +26,12 @@ + + + Classes + + + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..c559573 --- /dev/null +++ b/shell.nix @@ -0,0 +1,94 @@ +{ pkgs ? import { } }: + +let + projectInstall = pkgs.writeShellApplication { + name = "project-install"; + runtimeInputs = [ + pkgs.php81 + pkgs.php81Packages.composer + ]; + text = '' + composer install --prefer-dist --no-progress --working-dir="$PROJECT_ROOT" + ''; + }; + projectValidateComposer = pkgs.writeShellApplication { + name = "project-validate-composer"; + runtimeInputs = [ + pkgs.php81 + pkgs.php81Packages.composer + ]; + text = '' + composer validate + ''; + }; + projectValidateXml = pkgs.writeShellApplication { + name = "project-validate-xml"; + runtimeInputs = [ + pkgs.libxml2 + pkgs.wget + projectInstall + ]; + text = '' + project-install + xmllint --schema vendor/phpunit/phpunit/phpunit.xsd --noout phpunit.xml.dist + wget --no-check-certificate https://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd --output-document=xliff-core-1.2-strict.xsd + xmllint --schema xliff-core-1.2-strict.xsd --noout "$(find Resources -name '*.xlf')" + ''; + }; + projectCodingGuideline = pkgs.writeShellApplication { + name = "project-coding-guideline"; + runtimeInputs = [ + pkgs.php81 + projectInstall + ]; + text = '' + project-install + ./vendor/bin/php-cs-fixer fix --dry-run --diff + ''; + }; + projectTestAcceptance = pkgs.writeShellApplication { + name = "project-test-acceptance"; + runtimeInputs = [ + projectInstall + pkgs.sqlite + pkgs.firefox + pkgs.geckodriver + pkgs.php81 + ]; + text = '' + project-install + + _instance_path="$PROJECT_ROOT/.Build/web/typo3temp/var/tests/acceptance/" + + mkdir -p "$_instance_path" + + geckodriver > /dev/null 2>&1 & + _pid_geckodriver=$! + + TYPO3_PATH_APP="$_instance_path" \ + TYPO3_PATH_ROOT="$_instance_path" \ + php -S 127.0.0.1:8080 -t "$_instance_path" > /dev/null 2>&1 & + _pid_php=$! + + ./vendor/bin/codecept build + ./vendor/bin/codecept run + + kill "$_pid_geckodriver" "$_pid_php" + ''; + }; + +in pkgs.mkShell { + name = "TYPO3 Extension Watchlist"; + buildInputs = [ + projectValidateComposer + projectValidateXml + projectCodingGuideline + projectTestAcceptance + ]; + + shellHook = '' + export PROJECT_ROOT="$(pwd)" + + export typo3DatabaseDriver=pdo_sqlite + ''; +}