[FEATURE] Add JavaScript functionality (#2)

This commit is contained in:
Daniel Siepmann 2022-09-28 11:35:07 +02:00 committed by GitHub
parent 916b2723d5
commit d38af239ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 842 additions and 116 deletions

13
.gitattributes vendored Normal file
View file

@ -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

View file

@ -1,23 +1,20 @@
name: CI name: CI
on: on:
- pull_request - pull_request
jobs: jobs:
check-composer: check-composer:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install Composer - uses: cachix/install-nix-action@v17
uses: shivammathur/setup-php@v2
with: with:
php-version: 8.1 nix_path: nixpkgs=channel:nixos-unstable
coverage: none
tools: composer:v2
env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate composer.json - name: Validate composer.json
run: composer validate run: nix-shell --pure --run project-validate-composer
php-linting: php-linting:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -31,62 +28,42 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 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 - name: PHP lint
run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l" run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l"
xml-linting: xml-linting:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [check-composer]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install PHP - uses: cachix/install-nix-action@v17
uses: shivammathur/setup-php@v2
with: with:
php-version: "8.1" nix_path: nixpkgs=channel:nixos-unstable
coverage: none
tools: composer:v2
- name: Install xmllint - name: Validate XML
run: sudo apt-get install libxml2-utils run: nix-shell --pure --run project-validate-xml
- 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')
coding-guideline: coding-guideline:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- php-linting
- xml-linting
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install PHP - uses: cachix/install-nix-action@v17
uses: shivammathur/setup-php@v2
with: with:
php-version: "8.1" nix_path: nixpkgs=channel:nixos-unstable
coverage: none
tools: composer:v2
- name: Install dependencies - name: Check Coding Guideline
run: composer install --prefer-dist --no-progress --no-suggest run: nix-shell --pure --run project-coding-guideline
- name: Coding Guideline
run: ./vendor/bin/php-cs-fixer fix --dry-run --diff
code-quality: code-quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- php-linting
strategy: strategy:
matrix: matrix:
include: include:
@ -103,17 +80,16 @@ jobs:
coverage: none coverage: none
tools: composer:v2 tools: composer:v2
- name: Install dependencies with expected TYPO3 version - name: Install dependencies
run: composer require --no-interaction --prefer-dist --no-progress run: |-
composer require --no-interaction --prefer-dist --no-progress
./vendor/bin/codecept build
- name: Code Quality (by PHPStan) - name: Code Quality (by PHPStan)
run: ./vendor/bin/phpstan analyse run: ./vendor/bin/phpstan analyse
tests-mysql: tests-mysql:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- php-linting
- xml-linting
strategy: strategy:
matrix: matrix:
include: include:
@ -137,14 +113,32 @@ jobs:
mysql database: 'typo3' mysql database: 'typo3'
mysql root password: 'root' 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 - name: Install dependencies
run: composer require --no-interaction --prefer-dist --no-progress run: composer install --no-interaction --prefer-dist --no-progress
- name: PHPUnit Tests - name: PHPUnit Tests
run: |- env:
export typo3DatabaseDriver="pdo_mysql" typo3DatabaseDriver: "pdo_mysql"
export typo3DatabaseName="typo3" typo3DatabaseName: "typo3"
export typo3DatabaseHost="127.0.0.1" typo3DatabaseHost: "127.0.0.1"
export typo3DatabaseUsername="root" typo3DatabaseUsername: "root"
export typo3DatabasePassword="root" typo3DatabasePassword: "root"
./vendor/bin/phpunit --testdox 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

3
.gitignore vendored
View file

@ -1,5 +1,4 @@
/composer.lock /composer.lock
/.php-cs-fixer.cache /.php-cs-fixer.cache
/.Build/
/vendor/ /vendor/
/public/
/var/

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2022 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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
);
}
}

View file

@ -23,25 +23,33 @@ declare(strict_types=1);
namespace WerkraumMedia\Watchlist\Session; namespace WerkraumMedia\Watchlist\Session;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Extbase\Property\PropertyMapper; use TYPO3\CMS\Extbase\Property\PropertyMapper;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
use WerkraumMedia\Watchlist\Domain\Model\Item; use WerkraumMedia\Watchlist\Domain\Model\Item;
use WerkraumMedia\Watchlist\Domain\Model\Watchlist; use WerkraumMedia\Watchlist\Domain\Model\Watchlist;
class Typo3FrontendSessionService implements SessionServiceInterface class CookieSessionService implements SessionServiceInterface
{ {
private PropertyMapper $propertyMapper; private PropertyMapper $propertyMapper;
public function __construct( private array $watchlists = [];
PropertyMapper $propertyMapper
) { // Seems to be a bug leading to different instances if we use constructor.
public function injectPropertyMapper(PropertyMapper $propertyMapper): void
{
$this->propertyMapper = $propertyMapper; $this->propertyMapper = $propertyMapper;
} }
public function getWatchlist(string $identifier): ?Watchlist public function getWatchlist(string $identifier): ?Watchlist
{ {
$items = $this->getTsfe()->fe_user->getSessionData('watchlist-' . $identifier) ?: []; $cookieName = $this->getCookieName();
if ($items === [] || is_array($items) === false) { $cookie = $this->getRequest()->getCookieParams()[$cookieName] ?? '';
$items = array_filter(explode(
',',
$this->getRequest()->getCookieParams()['watchlist'] ?? ''
));
if ($items === []) {
return null; return null;
} }
@ -61,13 +69,24 @@ class Typo3FrontendSessionService implements SessionServiceInterface
public function update(Watchlist $watchlist): void public function update(Watchlist $watchlist): void
{ {
$this->getTsfe()->fe_user->setAndSaveSessionData( $this->watchlists[$watchlist->getIdentifier()] = array_map(
'watchlist-' . $watchlist->getIdentifier(), fn (Item $item) => $item->getUniqueIdentifier(),
array_map(fn (Item $item) => $item->getUniqueIdentifier(), $watchlist->getItems()) $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'];
} }
} }

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2022 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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',
],
],
],
];

View file

@ -11,3 +11,7 @@ services:
WerkraumMedia\Watchlist\Domain\Items\Page\ItemHandler: WerkraumMedia\Watchlist\Domain\Items\Page\ItemHandler:
tags: ['watchlist.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

View file

@ -14,6 +14,7 @@ Feature set
=========== ===========
The extension provides an controller with actions to add and remove items from watchlist. 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. 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. but this is more a demonstration an used for testing.
Projects are way too different and should provide their own items, see "Custom Items". 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 Custom Items
============ ============
A developer needs to implement an ``ItemHandler`` and an class representing the ``Item``. 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: ``<type>-rest-of-identifier``.
Commas should not be used within the identifier.
``ItemHandler`` ``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``. 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
<span data-watchlist-counter class="watchlist-badge-counter"></span>
``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.

View file

@ -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();
});
})();

View file

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2022 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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);
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2022 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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
*/
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2022 Daniel Siepmann <coding@daniel-siepmann.de>
*
* 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',
],
];
}

View file

@ -0,0 +1,2 @@
*
!.gitignore

2
Tests/Acceptance/_output/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -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","<INCLUDE_TYPOSCRIPT: source=""FILE:EXT:watchlist/Tests/Fixtures/FrontendRendering.typoscript"">
<INCLUDE_TYPOSCRIPT: source=""FILE:EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript"">"
1 pages
2 uid pid slug title
3 1 0 / Page Title
4 2 1 /page-2 Page 2 Title
5 sys_template
6 uid pid root clear constants config
7 1 1 1 3 databasePlatform = mysql <INCLUDE_TYPOSCRIPT: source="FILE:EXT:watchlist/Tests/Fixtures/FrontendRendering.typoscript"> <INCLUDE_TYPOSCRIPT: source="FILE:EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript">

View file

@ -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 (
<span data-watchlist-counter class="watchlist-badge-counter"></span>
<button data-watchlist-item="page-1" class="watchlist-btn watchlist-inactive">
Page 1
<span class="add-to-list">zur Merkliste hinzufügen</span>
<span class="remove-from-list">von Merkliste entfernen</span>
</button>
<button data-watchlist-item="page-2" class="watchlist-btn watchlist-inactive">
Page 2
<span class="add-to-list">zur Merkliste hinzufügen</span>
<span class="remove-from-list">von Merkliste entfernen</span>
</button>
)
}

View file

@ -21,8 +21,9 @@ declare(strict_types=1);
* 02110-1301, USA. * 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\InternalRequest;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
@ -40,21 +41,15 @@ class BasicsTest extends FunctionalTestCase
'typo3conf/ext/watchlist', 'typo3conf/ext/watchlist',
]; ];
protected $pathsToProvideInTestInstance = [ protected $pathsToLinkInTestInstance = [
'typo3conf/ext/watchlist/Tests/Functional/Frontend/Fixtures/Sites/' => 'typo3conf/sites/', 'typo3conf/ext/watchlist/Tests/Fixtures/Sites' => 'typo3conf/sites',
]; ];
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->setUpBackendUserFromFixture(1); $this->importCSVDataSet(__DIR__ . '/../Fixtures/BasicDatabase.csv');
$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',
]);
} }
/** /**
@ -83,14 +78,14 @@ class BasicsTest extends FunctionalTestCase
$result = $this->executeFrontendRequest($request); $result = $this->executeFrontendRequest($request);
self::assertIsRedirect('http://localhost/?id=1', $result); self::assertIsRedirect('http://localhost/?id=1', $result);
self::assertHasCookie('fe_typo_user', $result); self::assertCookie('page-1', $this->getCookie($result));
$request = new InternalRequest(); $request = new InternalRequest();
$request = $request->withPageId(1); $request = $request->withPageId(1);
$request = $request->withHeader('Cookie', self::getCookies($result)); $request = $request->withHeader('Cookie', 'watchlist=page-1');
$result = $this->executeFrontendRequest($request); $result = $this->executeFrontendRequest($request);
self::assertStringContainsString('Page Title', $result->getBody()->__toString()); self::assertStringContainsString('<li>Page Title</li>', $result->getBody()->__toString());
} }
/** /**
@ -105,10 +100,10 @@ class BasicsTest extends FunctionalTestCase
$request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1'); $request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1');
$result = $this->executeFrontendRequest($request); $result = $this->executeFrontendRequest($request);
$cookies = self::getCookies($result); self::assertCookie('page-1', $this->getCookie($result));
$request = new InternalRequest(); $request = new InternalRequest();
$request = $request->withHeader('Cookie', $cookies); $request = $request->withHeader('Cookie', 'watchlist=page-1');
$request = $request->withPageId(1); $request = $request->withPageId(1);
$request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString()); $request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString());
$request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'remove'); $request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'remove');
@ -116,9 +111,11 @@ class BasicsTest extends FunctionalTestCase
$result = $this->executeFrontendRequest($request); $result = $this->executeFrontendRequest($request);
self::assertIsRedirect('http://localhost/?id=1', $result); 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 = new InternalRequest();
$request = $request->withHeader('Cookie', $cookies);
$request = $request->withPageId(1); $request = $request->withPageId(1);
$result = $this->executeFrontendRequest($request); $result = $this->executeFrontendRequest($request);
@ -132,13 +129,24 @@ class BasicsTest extends FunctionalTestCase
self::assertSame($redirectLocation, $result->getHeader('location')[0] ?? ''); 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);
} }
} }

View file

@ -1,3 +0,0 @@
pages
,uid,pid,slug,title
,1,0,/,Page Title
1 pages
2 ,uid,pid,slug,title
3 ,1,0,/,Page Title

View file

@ -1,2 +0,0 @@
page = PAGE
page.10 =< tt_content.watchlist_watchlist.20

33
codeception.dist.yml Normal file
View file

@ -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'

View file

@ -36,6 +36,8 @@
"typo3/cms-frontend": "^11.5" "typo3/cms-frontend": "^11.5"
}, },
"require-dev": { "require-dev": {
"codeception/codeception": "^4.2",
"codeception/module-webdriver": "^2.0",
"cweagans/composer-patches": "^1.7", "cweagans/composer-patches": "^1.7",
"friendsofphp/php-cs-fixer": "^3.11", "friendsofphp/php-cs-fixer": "^3.11",
"phpstan/extension-installer": "^1.1", "phpstan/extension-installer": "^1.1",
@ -59,22 +61,20 @@
}, },
"extra": { "extra": {
"typo3/cms": { "typo3/cms": {
"cms-package-dir": "{$vendor-dir}/typo3/cms", "app-dir": ".Build",
"extension-key": "watchlist", "extension-key": "watchlist",
"web-dir": "public" "web-dir": ".Build/web"
}
}, },
"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": {
"patches": { "patches": {
"typo3/testing-framework": { "typo3/testing-framework": {
"Support cookies in request": "patches/testing-framework-cookies.patch" "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);'"
]
} }
} }

View file

@ -4,6 +4,8 @@ parameters:
- Classes - Classes
- Configuration - Configuration
- Tests - Tests
excludePaths:
- Tests/Acceptance/Support/_generated/
checkMissingIterableValueType: false checkMissingIterableValueType: false
reportUnmatchedIgnoredErrors: true reportUnmatchedIgnoredErrors: true
checkGenericClassInNonGenericObjectType: false checkGenericClassInNonGenericObjectType: false

View file

@ -1,14 +1,14 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
backupGlobals="false" backupGlobals="false"
backupStaticAttributes="false" backupStaticAttributes="false"
beStrictAboutCoversAnnotation="true"
bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php" bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
colors="true" colors="true"
convertErrorsToExceptions="true" convertErrorsToExceptions="true"
convertWarningsToExceptions="true" convertWarningsToExceptions="true"
forceCoversAnnotation="true" forceCoversAnnotation="false"
processIsolation="false" processIsolation="false"
stopOnError="false" stopOnError="false"
stopOnFailure="false" stopOnFailure="false"
@ -17,12 +17,6 @@
verbose="false" verbose="false"
> >
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">Classes</directory>
</include>
</coverage>
<testsuites> <testsuites>
<testsuite name="unit"> <testsuite name="unit">
<directory>Tests/Unit/</directory> <directory>Tests/Unit/</directory>
@ -32,6 +26,12 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage>
<include>
<directory suffix=".php">Classes</directory>
</include>
</coverage>
<php> <php>
<env name="typo3DatabaseDriver" value="pdo_sqlite"/> <env name="typo3DatabaseDriver" value="pdo_sqlite"/>
</php> </php>

94
shell.nix Normal file
View file

@ -0,0 +1,94 @@
{ pkgs ? import <nixpkgs> { } }:
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
'';
}