diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index e37c0788..99d0dc28 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -10,5 +10,4 @@ categories: - "Documentation :books:" template: | ## What’s Changed - $CHANGES diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml new file mode 100644 index 00000000..ab60038a --- /dev/null +++ b/.github/workflows/build-docs.yml @@ -0,0 +1,15 @@ +name: Build Docs + +on: + push: + branches: + - develop + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - name: Run Release Drafter + uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 00000000..c4af9443 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,56 @@ +name: Build Release + +on: + push: + branches: + - '**\.build' + - 'release/*' + - '!**\.gen' + +jobs: + autocommit: + name: Build Release + runs-on: ubuntu-latest + container: + image: atk4/image:latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.ref }} + + - name: Install PHP dependencies + run: composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Composer unset version + run: composer config version --unset + + - name: Update composer.json + run: >- + php -r ' + $f = __DIR__ . "/composer.json"; + $data = json_decode(file_get_contents($f), true); + foreach ($data as $k => $v) { + if (preg_match("~^(.+)-release$~", $k, $matches)) { + $data[$matches[1]] = $data[$k]; unset($data[$k]); + } + } + $str = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + echo $str; + file_put_contents($f, $str); + ' + + - name: Composer validate config + run: composer validate --strict --no-check-lock && composer normalize --dry-run --no-check-lock + + - name: Commit + run: | + git config --global user.name "$(git show -s --format='%an')" + git config --global user.email "$(git show -s --format='%ae')" + git add -A && git diff --staged && git commit -m "Build Release" + + - name: Push + uses: ad-m/github-push-action@master + with: + branch: ${{ github.ref }}.gen + force: true + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bundler.yml b/.github/workflows/bundler.yml deleted file mode 100644 index 953db85b..00000000 --- a/.github/workflows/bundler.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Bundler - -on: create - -jobs: - autocommit: - name: Update to stable dependencies - if: startsWith(github.ref, 'refs/heads/release/') - runs-on: ubuntu-latest - container: - image: atk4/image:latest # https://github.com/atk4/image - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.ref }} - - run: echo ${{ github.ref }} - - name: Update to stable dependencies - run: | - # replaces X keys with X-release keys - jq '. as $in | reduce (keys_unsorted[] | select(endswith("-release")|not)) as $k ({}; . + {($k) : (($k + "-release") as $kr | $in | if has($kr) then .[$kr] else .[$k] end) } )' < composer.json > tmp && mv tmp composer.json - v=$(echo ${{ github.ref }} | cut -d / -f 4) - echo "::set-env name=version::$v" - - - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Setting release dependencies - - uses: ad-m/github-push-action@master - with: - branch: ${{ github.ref }} - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index 1a783cf3..00000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Release Drafter - -on: - push: - # branches to consider in the event; optional, defaults to all - branches: - - develop - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: toolmantim/release-drafter@v5.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml new file mode 100644 index 00000000..6cbb1df5 --- /dev/null +++ b/.github/workflows/test-unit.yml @@ -0,0 +1,205 @@ +name: Unit + +on: + pull_request: + push: + schedule: + - cron: '0 0/2 * * *' + +jobs: + smoke-test: + name: Smoke + runs-on: ubuntu-latest + container: + image: atk4/image:${{ matrix.php }} + strategy: + fail-fast: false + matrix: + php: ['latest'] + type: ['Phpunit'] + include: + - php: 'latest' + type: 'CodingStyle' + - php: 'latest' + type: 'StaticAnalysis' + env: + LOG_COVERAGE: "" + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Configure PHP + run: | + if [ -n "$LOG_COVERAGE" ]; then echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; else rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; fi + php --version + + - name: Setup cache 1/2 + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Setup cache 2/2 + if: ${{ !env.ACT }} + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-smoke-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install PHP dependencies + run: | + if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev ; fi + if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan --dev ; fi + composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Init + run: | + mkdir -p build/logs + + - name: "Run tests: Phpunit (only for Phpunit)" + if: matrix.type == 'Phpunit' + run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" + + - name: Check Coding Style (only for CodingStyle) + if: matrix.type == 'CodingStyle' + run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --diff --diff-format=udiff --verbose --show-progress=dots + + - name: Run Static Analysis (only for StaticAnalysis) + if: matrix.type == 'StaticAnalysis' + run: | + echo "memory_limit = 1G" > /usr/local/etc/php/conf.d/custom-memory-limit.ini + vendor/bin/phpstan analyse + + unit-test: + name: Unit + runs-on: ubuntu-latest + container: + image: atk4/image:${{ matrix.php }} + strategy: + fail-fast: false + matrix: + php: ['7.3', '7.4', 'latest'] + type: ['Phpunit'] + include: + - php: 'latest' + type: 'Phpunit Lowest' + - php: 'latest' + type: 'Phpunit Burn' + env: + LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == 'latest' && matrix.type == 'Phpunit' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Configure PHP + run: | + if [ -n "$LOG_COVERAGE" ]; then echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; else rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; fi + php --version + + - name: Setup cache 1/2 + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Setup cache 2/2 + if: ${{ !env.ACT }} + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install PHP dependencies + run: | + if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "Phpunit Lowest" ] && [ "${{ matrix.type }}" != "Phpunit Burn" ]; then composer remove --no-interaction --no-update phpunit/phpunit --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev ; fi + if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/phpstan --dev ; fi + composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader + if [ "${{ matrix.type }}" == "Phpunit Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader ; fi + if [ "${{ matrix.type }}" == "Phpunit Burn" ]; then sed -i 's/ *public function runBare(): void/public function runBare(): void { gc_collect_cycles(); $mem0 = memory_get_usage(); for ($i = 0; $i < '"$(if [ \"$GITHUB_EVENT_NAME\" == \"schedule\" ]; then echo 5; else echo 5; fi)"'; ++$i) { $this->_runBare(); if ($i === 0) { gc_collect_cycles(); $mem1 = memory_get_usage(); } } gc_collect_cycles(); $mem2 = memory_get_usage(); if ($mem2 - 4000 * 1024 > $mem0 || $mem2 - 1536 * 1024 > $mem1) { $this->onNotSuccessfulTest(new AssertionFailedError("Memory leak detected! (" . round($mem0 \/ (1024 * 1024), 3) . " + " . round(($mem1 - $mem0) \/ (1024 * 1024), 3) . " + " . round(($mem2 - $mem1) \/ (1024 * 1024), 3) . " MB, " . $i . " iterations)")); } } private function _runBare(): void/' vendor/phpunit/phpunit/src/Framework/TestCase.php && cat vendor/phpunit/phpunit/src/Framework/TestCase.php | grep '_runBare(' ; fi + + - name: Init + run: | + mkdir -p build/logs + + - name: "Run tests: Phpunit (only for Phpunit)" + if: startsWith(matrix.type, 'Phpunit') + run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" + + - name: Upload coverage logs (only for "latest" Phpunit) + if: env.LOG_COVERAGE + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: build/logs/clover.xml + + behat-test: + name: Behat + runs-on: ubuntu-latest + container: + image: atk4/image:${{ matrix.php }} + strategy: + fail-fast: false + matrix: + php: ['latest-npm'] + type: ['Chrome', 'Firefox', 'Chrome Lowest', 'Chrome Slow'] + env: + LOG_COVERAGE: '' + services: + selenium-chrome: + image: selenium/standalone-chrome:latest + options: --health-cmd "/opt/bin/check-grid.sh" + selenium-firefox: + image: selenium/standalone-firefox:latest + options: --health-cmd "/opt/bin/check-grid.sh" + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Configure PHP + run: | + if [ -n "$LOG_COVERAGE" ]; then echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; else rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; fi + php --version + + - name: Setup cache 1/2 + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Setup cache 2/2 + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install PHP dependencies + run: | + composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap phpunit/phpcov --dev + composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev + composer remove --no-interaction --no-update phpstan/phpstan --dev + composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader + if [ "${{ matrix.type }}" == "Chrome Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader ; fi + + - name: Init + run: | + mkdir -p build/logs + php demos/_demo-data/create-sqlite-db.php + + - name: "Run tests: Behat" + run: | + php -S 172.18.0.2:8888 > /dev/null 2>&1 & + sleep 1 + if [ "${{ matrix.type }}" == "Firefox" ]; then sed -i "s~chrome~firefox~" behat.yml.dist ; fi + if [ "${{ matrix.type }}" == "Chrome Slow" ]; then echo 'sleep(1);' >> demos/init-app.php ; fi + + # remove once https://github.com/minkphp/Mink/pull/801 + # and https://github.com/minkphp/MinkSelenium2Driver/pull/322 are released + sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink/src/Element/Element.php + sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink-selenium2-driver/src/Selenium2Driver.php + + vendor/bin/behat -vv --config behat.yml.dist diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index 760dd46d..00000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Unit Testing - -on: - pull_request: - push: - schedule: - - cron: '0 * * * *' - -jobs: - unit-test: - name: Unit - runs-on: ubuntu-latest - container: - image: atk4/image:${{ matrix.php }} # https://github.com/atk4/image - strategy: - fail-fast: false - matrix: - php: ['7.3', 'latest', '8.0'] - type: ['Phpunit'] - include: - - php: 'latest' - type: 'CodingStyle' - env: - LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == 'latest' && matrix.type == 'Phpunit' && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master')))] }}" - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Configure PHP - run: | - if [ -n "$LOG_COVERAGE" ]; then echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; else rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; fi - php --version - - - name: Setup cache 1/2 - id: composer-cache - run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Setup cache 2/2 - uses: actions/cache@v1 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Install PHP dependencies - run: | - if [ "${{ matrix.type }}" != "Phpunit" ]; then composer remove --no-interaction --no-update phpunit/phpunit --dev ; fi - if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev ; fi - composer install --no-suggest --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - - - name: Init - run: | - mkdir -p build/logs - - - name: "Run tests: Phpunit (only for Phpunit)" - if: matrix.type == 'Phpunit' - run: "vendor/bin/phpunit \"$(if [ -n \"$LOG_COVERAGE\" ]; then echo '--coverage-text'; else echo '--no-coverage'; fi)\" -v" - - - name: Lint / check syntax (only for CodingStyle) - if: matrix.type == 'CodingStyle' - run: find . \( -type d \( -path './vendor/*' \) \) -prune -o ! -type d -name '*.php' -print0 | xargs -0 -n1 php -l - - - name: Check Coding Style (only for CodingStyle) - if: matrix.type == 'CodingStyle' - run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --diff --diff-format=udiff --verbose --show-progress=dots - - - name: Upload coverage logs (only for "latest" Phpunit) - if: env.LOG_COVERAGE - uses: codecov/codecov-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: build/logs/clover.xml - -# Behat tests technically work, but need to refactor demos to be easyer to test them -# behat-test: -# name: Behat -# runs-on: ubuntu-latest -# container: -# image: atk4/image:${{ matrix.php }} -# strategy: -# fail-fast: false -# matrix: -# php: ['latest'] -# type: ['Chrome', 'Firefox', 'Chrome Lowest'] -# env: -# LOG_COVERAGE: '' -# services: -# selenium-chrome: -# image: selenium/standalone-chrome:latest -# options: --health-cmd "/opt/bin/check-grid.sh" -# selenium-firefox: -# image: selenium/standalone-firefox:latest -# options: --health-cmd "/opt/bin/check-grid.sh" -# steps: -# - name: Checkout -# uses: actions/checkout@v2 -# -# - name: Configure PHP -# run: | -# if [ -z "$LOG_COVERAGE" ]; then rm /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; fi -# php --version -# -# - name: Setup cache 1/2 -# id: composer-cache -# run: | -# echo "::set-output name=dir::$(composer config cache-files-dir)" -# -# - name: Setup cache 2/2 -# uses: actions/cache@v1 -# with: -# path: ${{ steps.composer-cache.outputs.dir }} -# key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.type }}-${{ hashFiles('composer.json') }} -# restore-keys: | -# ${{ runner.os }}-composer- -# -# - name: Install PHP dependencies -# run: | -# composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap phpunit/phpcov --dev -# composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev -# composer install --no-suggest --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader -# if [ "${{ matrix.type }}" == "Chrome Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader ; fi -# -# - name: Init -# run: | -# mkdir -p build/logs -# php demos/_demo-data/create-sqlite-db.php -# -# - name: "Run tests: Behat" -# run: | -# php -S 172.18.0.2:8888 > /dev/null 2>&1 & -# sleep 0.2 -# if [ "${{ matrix.type }}" == "Firefox" ]; then sed -i "s~chrome~firefox~" behat.yml.dist ; fi -# -# # remove once https://github.com/minkphp/Mink/pull/801 -# # and https://github.com/minkphp/MinkSelenium2Driver/pull/322 are released -# sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink/src/Element/Element.php -# sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink-selenium2-driver/src/Selenium2Driver.php -# -# vendor/bin/behat --config behat.yml.dist diff --git a/.php_cs.dist b/.php-cs-fixer.dist.php similarity index 95% rename from .php_cs.dist rename to .php-cs-fixer.dist.php index 1e800065..aad5eedb 100644 --- a/.php_cs.dist +++ b/.php-cs-fixer.dist.php @@ -1,15 +1,16 @@ in([__DIR__]) ->exclude([ 'cache', 'build', 'vendor', - ]); + ]) + ->in(__DIR__) +; -return PhpCsFixer\Config::create() - ->setRiskyAllowed(true) +$config = new PhpCsFixer\Config(); +$config->setRiskyAllowed(true) ->setRules([ '@PhpCsFixer' => true, '@PhpCsFixer:risky' =>true, @@ -67,3 +68,5 @@ ]) ->setFinder($finder) ->setCacheFile(__DIR__ . '/.php_cs.cache'); + +return $config; \ No newline at end of file diff --git a/behat.yml.dist b/behat.yml.dist index 894c0bf9..9d993570 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -4,7 +4,7 @@ default: paths: features: '%paths.base%/tests-behat' contexts: - - atk4\login\behat\ContextDump + - Atk4\Login\Behat\Context - Behat\MinkExtension\Context\MinkContext extensions: Behat\MinkExtension: @@ -20,4 +20,4 @@ default: chrome: args: - '--headless' - - '--window-size=1280,720' + - '--window-size=1930,1200' \ No newline at end of file diff --git a/composer.json b/composer.json index 81981a98..4e398efb 100644 --- a/composer.json +++ b/composer.json @@ -29,29 +29,27 @@ }, "require": { "php": ">=7.3.0", - "atk4/ui": "dev-develop", - "atk4/data": "dev-develop" + "atk4/ui": "2.4.*", + "atk4/data": "2.4.*" }, "require-release": { "php": ">=7.3.0", - "atk4/ui": "~2.3.0", - "atk4/data": "~2.3.0" + "atk4/ui": "~2.4.0" }, "require-dev": { - "behat/behat": "^3.7", + "behat/behat": "^3.8", "behat/mink": "^1.8", "behat/mink-extension": "^2.3.1", "behat/mink-selenium2-driver": "^1.4", - "friendsofphp/php-cs-fixer": "^2.16", + "ergebnis/composer-normalize": "^2.13", + "friendsofphp/php-cs-fixer": "^2.17", + "johnkary/phpunit-speedtrap": "^3.2", + "instaclick/php-webdriver": "^1.4.7", + "phpstan/phpstan": "^0.12.82", "phpunit/phpcov": "*", "phpunit/phpunit": ">=9.3", "symfony/contracts": ">=1.1" }, - "require-dev-release": { - "friendsofphp/php-cs-fixer": "^2.16", - "phpunit/phpunit": "*", - "symfony/contracts": ">=1.1" - }, "autoload": { "psr-4": { "Atk4\\Login\\": "src/" @@ -59,7 +57,7 @@ }, "autoload-dev": { "psr-4": { - "Atk4\\Login\\Behat\\": "tests-behat/bootstrap/", + "Atk4\\Login\\Behat\\": "tests-behat/Bootstrap/", "Atk4\\Login\\Demo\\": "demos/src/", "Atk4\\Login\\Tests\\": "tests/" } diff --git a/demos/_demo-data/create-sqlite-db.php b/demos/_demo-data/create-sqlite-db.php new file mode 100644 index 00000000..40e79a79 --- /dev/null +++ b/demos/_demo-data/create-sqlite-db.php @@ -0,0 +1,52 @@ + 'login_user']); +$model->addField('name', ['type' => 'string']); +$model->addField('email', ['type' => 'string']); +$model->addField('password', ['type' => 'string']); +$model->addField('role_id', ['type' => 'integer']); +(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +$model->import([ + 1 => ['id' => 1, 'name' => 'Standard User', 'email' => 'user', 'password' => '$2y$10$BwEhcP8f15yOexf077VTHOnySn/mit49ZhpfeBkORQhrsmHr4U6Qy', 'role_id' => 1], // user/user + 2 => ['id' => 2, 'name' => 'Administrator', 'email' => 'admin', 'password' => '$2y$10$p34ciRcg9GZyxukkLIaEnenGBao79fTFa4tFSrl7FvqrxnmEGlD4O', 'role_id' => 2], // admin/admin +]); + +$model = new \Atk4\Data\Model($persistence, ['table' => 'login_role']); +$model->addField('name', ['type' => 'string']); +(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +$model->import([ + 1 => ['id' => 1, 'name' => 'User Role'], + 2 => ['id' => 2, 'name' => 'Admin Role'], +]); + +$model = new \Atk4\Data\Model($persistence, ['table' => 'login_access_role']); +$model->addField('role_id', ['type' => 'integer']); +$model->addField('model', ['type' => 'string']); +$model->addField('all_visible', ['type' => 'boolean']); +$model->addField('visible_fields', ['type' => 'boolean']); +$model->addField('all_editable', ['type' => 'boolean']); +$model->addField('editable_fields', ['type' => 'boolean']); +$model->addField('all_actions', ['type' => 'boolean']); +$model->addField('actions', ['type' => 'boolean']); +$model->addField('conditions', ['type' => 'boolean']); + +(new \Atk4\Schema\Migration($model))->dropIfExists()->create(); +$model->import([ + 1 => ['id' => 1, 'role_id' => 1, 'model' => '\\Atk4\Login\\Model\\User', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 0, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], + 2 => ['id' => 2, 'role_id' => 2, 'model' => '\\Atk4\Login\\Model\\User', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 1, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], + 3 => ['id' => 3, 'role_id' => 2, 'model' => '\\Atk4\Login\\Model\\Role', 'all_visible' => 1, 'visible_fields' => null, 'all_editable' => 1, 'editable_fields' => null, 'all_actions' => 1, 'actions' => null, 'conditions' => null], +]); + +echo 'import complete!' . "\n"; diff --git a/demos/acl-clients.php b/demos/acl-clients.php index 97c72620..6989315e 100644 --- a/demos/acl-clients.php +++ b/demos/acl-clients.php @@ -8,7 +8,8 @@ use Atk4\Ui\Header; use Atk4\Ui\Message; -include 'init.php'; +/** @var App $app */ +include __DIR__ . '/init-app.php'; Header::addTo($app, [ 'Client list for ACL testing', @@ -18,8 +19,7 @@ // switch on ACL so it will be applied for all models added to persistence from now on $app->initAcl(); -$app->add([Message::class, 'type' => 'info']) +Message::addTo($app, ['type' => 'info']) ->set('This is how an ACL managed app will look like based on logged in user and his role and permissions.'); -$app->add(new Crud()) - ->setModel(new Model\Client($app->db)); +Crud::addTo($app)->setModel(new Model\Client($app->db)); diff --git a/demos/admin-roles.php b/demos/admin-roles.php index 99adcac9..9b4a978f 100644 --- a/demos/admin-roles.php +++ b/demos/admin-roles.php @@ -8,9 +8,9 @@ use Atk4\Login\RoleAdmin; use Atk4\Ui\Header; -include 'init.php'; +/** @var App $app */ +include __DIR__ . '/init-app.php'; Header::addTo($app)->set('Roles'); -$crud = RoleAdmin::addTo($app); -$crud->setModel(new Role($app->db)); +RoleAdmin::addTo($app)->setModel(new Role($app->db)); diff --git a/demos/admin-setup.php b/demos/admin-setup.php index 7f9ae03f..7d40ce62 100644 --- a/demos/admin-setup.php +++ b/demos/admin-setup.php @@ -14,7 +14,8 @@ use Atk4\Ui\Message; use Atk4\Ui\View; -require 'init.php'; +/** @var App $app */ +require __DIR__ . '/init-app.php'; Header::addTo($app, ['Setup demo database']); diff --git a/demos/admin-users.php b/demos/admin-users.php index 4a761061..7daf12e7 100644 --- a/demos/admin-users.php +++ b/demos/admin-users.php @@ -8,8 +8,8 @@ use Atk4\Login\UserAdmin; use Atk4\Ui\Header; -include 'init.php'; +/** @var App $app */ +include __DIR__ . '/init-app.php'; Header::addTo($app)->set('Users'); -$app->add(new UserAdmin()) - ->setModel(new User($app->db)); +UserAdmin::addTo($app)->setModel(new User($app->db)); diff --git a/demos/form-forgot.php b/demos/form-forgot.php index 1e6b23bf..d5080872 100644 --- a/demos/form-forgot.php +++ b/demos/form-forgot.php @@ -7,12 +7,11 @@ use Atk4\Ui\Header; use Atk4\Ui\View; -require 'init.php'; +/** @var App $app */ +require __DIR__ . '/init-app.php'; Header::addTo($app, ['Forgot password form']); - -$v = View::addTo($app, ['ui' => 'segment']); -$v->set('Not implemented'); +View::addTo($app, ['ui' => 'segment'])->set('Not implemented'); /* $f = Form\ForgotPassword::addTo($v, [ 'linkSuccess' => ['index'], diff --git a/demos/form-login.php b/demos/form-login.php index 3a85ee8d..03813224 100644 --- a/demos/form-login.php +++ b/demos/form-login.php @@ -4,7 +4,8 @@ namespace Atk4\Login\Demo; -require 'init.php'; +/** @var App $app */ +require __DIR__ . '/init-app.php'; $app->auth->logout(); $app->auth->displayLoginForm(); diff --git a/demos/form-register.php b/demos/form-register.php index 1b6c9d42..b8b37eb4 100644 --- a/demos/form-register.php +++ b/demos/form-register.php @@ -5,15 +5,18 @@ namespace Atk4\Login\Demo; use Atk4\Login\Form; +use Atk4\Login\Layout\Narrow; use Atk4\Login\Model\User; use Atk4\Ui\Header; -use Atk4\Ui\View; -require 'init.php'; +/** @var App $app */ +require __DIR__ . '/init-app.php'; +// @phpstan-ignore-next-line +$app->html = null; +$app->initLayout([Narrow::class]); Header::addTo($app, ['New user sign-up form']); -$v = View::addTo($app, ['ui' => 'segment']); -$f = Form\Register::addTo($v); +$f = Form\Register::addTo($app, ['auth' => $app->auth]); $m = new User($app->db); $f->setModel($m); diff --git a/demos/index.php b/demos/index.php index f6d1856a..c252745a 100644 --- a/demos/index.php +++ b/demos/index.php @@ -9,7 +9,8 @@ use Atk4\Ui\Message; use Atk4\Ui\View; -require 'init.php'; +/** @var App $app */ +require __DIR__ . '/init-app.php'; Header::addTo($app, ['Welcome to Auth Add-on demo app']); @@ -20,10 +21,10 @@ // Info if (isset($app->auth) && $app->auth->isLoggedIn()) { $a = Message::addTo($app, ['type' => 'info'])->set('Currently logged in: ' . $app->auth->user->getTitle()); - Button::addTo($a, ['Logout', 'icon' => 'sign out'])->on('click', $app->jsRedirect([$app->auth->pageDashboard, 'logout' => true])); + Button::addTo($a, ['Logout', 'icon' => 'sign out'])->link([$app->auth->pageDashboard, 'logout' => true]); } else { $a = Message::addTo($app, ['type' => 'info'])->set('Currently there is no user logged in'); - Button::addTo($a, ['Login', 'icon' => 'key'])->on('click', $app->jsRedirect(['form-login'])); + Button::addTo($a, ['Login', 'icon' => 'key'])->link(['form-login']); } // Addon description diff --git a/demos/init.php b/demos/init-app.php similarity index 71% rename from demos/init.php rename to demos/init-app.php index 05b66303..8ef3e97c 100644 --- a/demos/init.php +++ b/demos/init-app.php @@ -4,7 +4,7 @@ namespace Atk4\Login\Demo; -include '../vendor/autoload.php'; +include __DIR__ . '/../vendor/autoload.php'; // init App $app = new App(); diff --git a/demos/src/AbstractApp.php b/demos/src/AbstractApp.php index 8922c21e..580382a3 100644 --- a/demos/src/AbstractApp.php +++ b/demos/src/AbstractApp.php @@ -4,6 +4,8 @@ namespace Atk4\Login\Demo; +use Atk4\Data\Persistence; + /** * Application which use demo database. */ diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..f63fbc28 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,134 @@ +includes: + - vendor/mahalux/atk4-hintable/phpstan-ext.neon + +parameters: + level: 4 + paths: + - ./ + excludes_analyse: + - cache/ + - build/ + - vendor/ + + # TODO review once we drop PHP 7.x support + treatPhpDocTypesAsCertain: false + + ignoreErrors: + - '~^Unsafe usage of new static\(\)\.$~' + + # TODO these rules are generated, this ignores should be fixed in the code + # level 0 + + - + message: "#^Instantiated class Atk4\\\\Data\\\\Model\\\\AccessRule not found\\.$#" + count: 1 + path: src/RoleAdmin.php + + # level 1 + - + message: "#^Call to an undefined method Atk4\\\\Login\\\\Model\\\\AccessRule\\:\\:setUnique\\(\\)\\.$#" + count: 2 + path: src/Model/AccessRule.php + + - + message: "#^Method Atk4\\\\Ui\\\\View\\:\\:setModel\\(\\) invoked with 2 parameters, 1 required\\.$#" + count: 1 + path: src/Form/Control/Actions.php + + - + message: "#^Method Atk4\\\\Ui\\\\View\\:\\:setModel\\(\\) invoked with 2 parameters, 1 required\\.$#" + count: 1 + path: src/Form/Control/Fields.php + + # level 2 + - + message: "#^Access to an undefined property Atk4\\\\Ui\\\\Layout\\:\\:\\$menuLeft\\.$#" + count: 5 + path: demos/src/App.php + + - + message: "#^Call to an undefined method Atk4\\\\Ui\\\\AbstractView\\:\\:getUrl\\(\\)\\.$#" + count: 1 + path: src/Auth.php + + - + message: "#^Method Atk4\\\\Login\\\\Form\\\\Control\\\\Fields\\:\\:setModel\\(\\) should return Atk4\\\\Data\\\\Model but return statement is missing\\.$#" + count: 1 + path: src/Form/Control/Fields.php + + - + message: "#^Call to an undefined method Atk4\\\\Ui\\\\Form\\\\Control\\:\\:addAction\\(\\)\\.$#" + count: 1 + path: src/Form/Login.php + + - + message: "#^Call to an undefined method Atk4\\\\Ui\\\\Form\\\\Control\\:\\:setInputAttr\\(\\)\\.$#" + count: 2 + path: src/Form/Register.php + + - + message: "#^Call to an undefined method Atk4\\\\Data\\\\Reference\\\\HasOne\\:\\:withTitle\\(\\)\\.$#" + count: 1 + path: src/Model/AccessRule.php + + - + message: "#^Call to an undefined method Atk4\\\\Data\\\\Reference\\\\HasOne\\:\\:withTitle\\(\\)\\.$#" + count: 1 + path: src/Model/User.php + + - + message: "#^Call to an undefined method Atk4\\\\Ui\\\\Table\\\\Column\\:\\:addModal\\(\\)\\.$#" + count: 1 + path: src/RoleAdmin.php + + - + message: "#^Call to method addCondition\\(\\) on an unknown class Atk4\\\\Data\\\\Model\\\\AccessRule\\.$#" + count: 1 + path: src/RoleAdmin.php + + - + message: "#^Call to an undefined method Atk4\\\\Ui\\\\Table\\\\Column\\:\\:addModal\\(\\)\\.$#" + count: 1 + path: src/UserAdmin.php + + - + message: "#^Call to an undefined method Atk4\\\\Data\\\\Field\\:\\:suggestPassword\\(\\)\\.$#" + count: 1 + path: src/UserAdmin.php + + - + message: "#^Call to an undefined method Atk4\\\\Data\\\\Field\\:\\:verify\\(\\)\\.$#" + count: 7 + path: tests/PasswordFieldTest.php + + #level 3 + - + message: "#^Method Atk4\\\\Login\\\\Acl\\:\\:getRules\\(\\) should return Atk4\\\\Login\\\\Model\\\\AccessRule but returns Atk4\\\\Data\\\\Model\\.$#" + count: 1 + path: src/Acl.php + + - + message: "#^Property Atk4\\\\Ui\\\\App\\:\\:\\$html \\(Atk4\\\\Ui\\\\View\\) does not accept null\\.$#" + count: 1 + path: src/Auth.php + + - + message: "#^Method Atk4\\\\Login\\\\Form\\\\Control\\\\Generic\\:\\:getModel\\(\\) should return Atk4\\\\Data\\\\Model\\|null but empty return statement found\\.$#" + count: 2 + path: src/Form/Control/Generic.php + + # level 4 + - + message: "#^Else branch is unreachable because previous condition is always true\\.$#" + count: 1 + path: src/Field/Password.php + + - + message: "#^Else branch is unreachable because ternary operator condition is always true\\.$#" + count: 1 + path: src/Field/Password.php + + - + message: "#^If condition is always true\\.$#" + count: 1 + path: src/Form/Login.php diff --git a/src/Auth.php b/src/Auth.php index 677542dc..13cefc17 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -13,6 +13,7 @@ use Atk4\Core\TrackableTrait; use Atk4\Data\Model; use Atk4\Data\Persistence; +use Atk4\Login\Cache\Session; use Atk4\Login\Layout\Narrow; use Atk4\Ui\Layout\Admin; use Atk4\Ui\VirtualPage; @@ -93,7 +94,7 @@ class Auth /** * Cache object. * - * @var Cache + * @var Session */ protected $cache; @@ -211,8 +212,8 @@ public function setModel($model, string $fieldLogin = null, string $fieldPasswor */ protected function loadFromCache(): void { - $this->user->data = $this->cache->getData(); - $this->user->setId($this->user->data[$this->user->id_field] ?? null); + $this->user->setMulti($this->cache->getData()); + $this->user->setId($this->cache->getData()[$this->user->id_field] ?? null); } /** diff --git a/src/Field/Password.php b/src/Field/Password.php index 300f3c76..d27be83c 100644 --- a/src/Field/Password.php +++ b/src/Field/Password.php @@ -8,7 +8,7 @@ use Atk4\Data\Exception; use Atk4\Data\Field; use Atk4\Data\Persistence; -use Atk4\Ui\Persistence\UI; +use Atk4\Ui\Persistence\Ui; class Password extends Field { @@ -23,7 +23,7 @@ class Password extends Field * Keeping the actual hash protected, in case we have to validate password with * compare(). * - * @var string + * @var string|null */ protected $passwordHash; @@ -103,7 +103,7 @@ public function normalize($value) * also update $this->passwordHash, in case you'll want to perform * verify right after. * - * @param string $password plaintext password + * @param string|null $password plaintext password * * @return string|null encrypted password */ @@ -127,14 +127,14 @@ public function encrypt(?string $password, Field $f, Persistence $p) * DO NOT CALL THIS METHOD. It is automatically invoked when you load * your model. * - * @param string $password encrypted password + * @param string|null $password encrypted password * * @return string|null encrypted password */ public function decrypt(?string $password, Field $f, Persistence $p) { $this->passwordHash = $password; - if ($p instanceof UI) { + if ($p instanceof Ui) { return $password; } diff --git a/src/Form/Login.php b/src/Form/Login.php index 96609056..1b627a86 100644 --- a/src/Form/Login.php +++ b/src/Form/Login.php @@ -26,9 +26,6 @@ class Login extends Form /** @var false|string show cookie warning? */ public $cookieWarning = 'This website uses web cookie to remember you while you are logged in.'; - /** - * Intialization. - */ protected function init(): void { parent::init(); @@ -39,8 +36,8 @@ protected function init(): void $form->buttonSave->addClass('large fluid'); $form->buttonSave->iconRight = 'right arrow'; - $form->addControl('email', null, ['required' => true]); - $p = $form->addControl('password', [Control\Password::class], ['required' => true]); + $form->addControl($this->auth->fieldLogin, null, ['required' => true]); + $p = $form->addControl($this->auth->fieldPassword, [Control\Password::class], ['required' => true]); if ($this->linkForgot) { $p->addAction(['icon' => 'question']) @@ -57,7 +54,7 @@ protected function init(): void if ($this->auth) { $this->onSubmit(function ($form) { // try to log user in - if ($this->auth->tryLogin($form->model->get('email'), $form->model->get('password'))) { + if ($this->auth->tryLogin($form->model->get($this->auth->fieldLogin), $form->model->get($this->auth->fieldPassword))) { return $this->getApp()->jsRedirect($this->linkSuccess); } diff --git a/src/Form/Register.php b/src/Form/Register.php index 5471066d..d1527311 100644 --- a/src/Form/Register.php +++ b/src/Form/Register.php @@ -5,6 +5,7 @@ namespace Atk4\Login\Form; use Atk4\Data\Model; +use Atk4\Login\Auth; use Atk4\Ui\Form; /** @@ -12,16 +13,9 @@ */ class Register extends Form { - /** - * Which field to look up user by. - * - * @var string - */ - public $fieldLogin = 'email'; + /** @var Auth object */ + public $auth; - /** - * Initialization. - */ protected function init(): void { parent::init(); @@ -57,9 +51,9 @@ public function setModel(Model $user, $fields = null) // Look if user already exist? $c = clone $this->model; $c->unload(); - $c->tryLoadBy($this->fieldLogin, strtolower($form->model->get('email'))); + $c->tryLoadBy($this->auth->fieldLogin, strtolower($form->model->get($this->auth->fieldLogin))); if ($c->loaded()) { - return $form->error('email', 'User with this email already exist'); + return $form->error($this->auth->fieldLogin, 'User with this email already exist'); } // check if passwords match diff --git a/tests-behat/Bootstrap/Context.php b/tests-behat/Bootstrap/Context.php new file mode 100644 index 00000000..48bc8a80 --- /dev/null +++ b/tests-behat/Bootstrap/Context.php @@ -0,0 +1,714 @@ +getMink()->getSession($name); + } + + /** + * @BeforeStep + */ + public function closeAllToasts(BeforeStepScope $event): void + { + if (!$this->getSession()->getDriver()->isStarted()) { + return; + } + + if (strpos($event->getStep()->getText(), 'Toast display should contains text ') !== 0) { + $this->getSession()->executeScript('$(\'.toast-box > .ui.toast\').toast(\'close\');'); + } + } + + /** + * @AfterStep + */ + public function waitUntilLoadingAndAnimationFinished(AfterStepScope $event): void + { + $this->jqueryWait(); + $this->disableAnimations(); + $this->assertNoException(); + $this->disableDebounce(); + } + + protected function disableAnimations(): void + { + // disable all CSS/jQuery animations/transitions + $toCssFx = function ($selector, $cssPairs) { + $css = []; + foreach ($cssPairs as $k => $v) { + foreach ([$k, '-moz-' . $k, '-webkit-' . $k] as $k2) { + $css[] = $k2 . ': ' . $v . ' !important;'; + } + } + + return $selector . ' { ' . implode(' ', $css) . ' }'; + }; + + $durationAnimation = 0.005; + $durationToast = 5; + $css = $toCssFx('*', [ + 'animation-delay' => $durationAnimation . 's', + 'animation-duration' => $durationAnimation . 's', + 'transition-delay' => $durationAnimation . 's', + 'transition-duration' => $durationAnimation . 's', + ]) . $toCssFx('.ui.toast-container .toast-box .progressing.wait', [ + 'animation-duration' => $durationToast . 's', + 'transition-duration' => $durationToast . 's', + ]); + + $this->getSession()->executeScript( + 'if (Array.prototype.filter.call(document.getElementsByTagName("style"), e => e.getAttribute("about") === "atk-test-behat").length === 0) {' + . ' $(\'\').appendTo(\'head\');' + . ' }' + . 'jQuery.fx.off = true;' + ); + } + + protected function assertNoException(): void + { + foreach ($this->getSession()->getPage()->findAll('css', 'div.ui.negative.icon.message > div.content > div.header') as $elem) { + if ($elem->getText() === 'Critical Error') { + throw new Exception('Page contains uncaught exception'); + } + } + } + + protected function disableDebounce(): void + { + $this->getSession()->executeScript('atk.options.set("debounceTimeout", 20)'); + } + + /** + * Sleep for a certain time in ms. + * + * @Then I wait :arg1 ms + */ + public function iWait($arg1) + { + $this->getSession()->wait($arg1); + } + + /** + * @When I press button :arg1 + */ + public function iPressButton($arg1) + { + $button = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); + // store button id. + $this->buttonId = $button->getAttribute('id'); + // fix "is out of bounds of viewport width and height" for Firefox + $button->focus(); + $button->click(); + } + + /** + * @Then I press menu button :arg1 using class :arg2 + */ + public function iPressMenuButtonUsingClass($arg1, $arg2) + { + $menu = $this->getSession()->getPage()->find('css', '.ui.menu.' . $arg2); + if (!$menu) { + throw new Exception('Unable to find a menu with class ' . $arg2); + } + + $link = $menu->find('xpath', '//a[text()="' . $arg1 . '"]'); + if (!$link) { + throw new Exception('Unable to find menu with title ' . $arg1); + } + + $this->getSession()->executeScript('$("#' . $link->getAttribute('id') . '").click()'); + } + + /** + * @Then I set calendar input name :arg1 with value :arg2 + */ + public function iSetCalendarInputNameWithValue($arg1, $arg2) + { + $script = '$(\'input[name="' . $arg1 . '"]\').get(0)._flatpickr.setDate("' . $arg2 . '")'; + $this->getSession()->executeScript($script); + } + + /** + * @Given I click link :arg1 + */ + public function iClickLink($arg1) + { + $link = $this->getSession()->getPage()->find('xpath', '//a[text()="' . $arg1 . '"]'); + $link->click(); + } + + /** + * @Then I click filter column name :arg1 + */ + public function iClickFilterColumnName($arg1) + { + $column = $this->getSession()->getPage()->find('css', "th[data-column='" . $arg1 . "']"); + if (!$column) { + throw new Exception('Unable to find a column ' . $arg1); + } + + $icon = $column->find('css', 'i'); + if (!$icon) { + throw new Exception('Column does not contain clickable icon.'); + } + + $this->getSession()->executeScript('$("#' . $icon->getAttribute('id') . '").click()'); + } + + /** + * @Given I click tab with title :arg1 + */ + public function iClickTabWithTitle($arg1) + { + $tabMenu = $this->getSession()->getPage()->find('css', '.ui.tabular.menu'); + if (!$tabMenu) { + throw new Exception('Unable to find a tab menu.'); + } + + $link = $tabMenu->find('xpath', '//a[text()="' . $arg1 . '"]'); + if (!$link) { + throw new Exception('Unable to find tab with title ' . $arg1); + } + + $this->getSession()->executeScript('$("#' . $link->getAttribute('id') . '").click()'); + } + + /** + * @Then I click first card on page + */ + public function iClickFirstCardOnPage() + { + $this->getSession()->executeScript('$(".atk-card")[0].click()'); + } + + /** + * @Then I click first element using class :arg1 + */ + public function iClickFirstElementUsingClass($arg1) + { + $this->getSession()->executeScript('$("' . $arg1 . '")[0].click()'); + } + + /** + * @Then I click paginator page :arg1 + */ + public function iClickPaginatorPage($arg1) + { + $this->getSession()->executeScript('$("a.item[data-page=' . $arg1 . ']").click()'); + } + + /** + * @Then I see button :arg1 + */ + public function iSee($arg1) + { + $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); + if ($element->getAttribute('style')) { + throw new Exception("Element with text \"{$arg1}\" must be invisible"); + } + } + + /** + * @Then dump :arg1 + */ + public function dump($arg1) + { + $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); + var_dump($element->getOuterHtml()); + } + + /** + * @Then I don't see button :arg1 + */ + public function iDontSee($arg1) + { + $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); + if (mb_strpos('display: none', $element->getAttribute('style')) !== false) { + throw new Exception("Element with text \"{$arg1}\" must be invisible"); + } + } + + /** + * @Then Label changes to a number + */ + public function labelChangesToNumber() + { + $element = $this->getSession()->getPage()->findById($this->buttonId); + $value = trim($element->getHtml()); + if (!is_numeric($value)) { + throw new Exception('Label must be numeric on button: ' . $this->buttonId . ' : ' . $value); + } + } + + /** + * @Then /^container "([^"]*)" should display "([^"]*)" item\(s\)$/ + */ + public function containerShouldHaveNumberOfItem($selector, int $numberOfitems) + { + $items = $this->getSession()->getPage()->findAll('css', $selector); + $count = 0; + foreach ($items as $el => $item) { + ++$count; + } + if ($count !== $numberOfitems) { + throw new Exception('Items does not match. There were ' . $count . ' item in container'); + } + } + + /** + * @Then I press Modal button :arg + */ + public function iPressModalButton($arg) + { + $modal = $this->getSession()->getPage()->find('css', '.modal.transition.visible.active.front'); + if ($modal === null) { + throw new Exception('No modal found'); + } + // find button in modal + $btn = $modal->find('xpath', '//div[text()="' . $arg . '"]'); + if (!$btn) { + throw new Exception('Cannot find button in modal'); + } + $btn->click(); + } + + /** + * @Then Modal is open with text :arg1 + * + * Check if text is present in modal or dynamic modal. + */ + public function modalIsOpenWithText($arg1) + { + $modal = $this->waitForNodeElement('.modal.transition.visible.active.front'); + if ($modal === null) { + throw new Exception('No modal found'); + } + // find text in modal + $text = $modal->find('xpath', '//div[text()="' . $arg1 . '"]'); + if (!$text || trim($text->getText()) !== $arg1) { + throw new Exception('No such text in modal'); + } + } + + /** + * @Then Modal is showing text :arg1 inside tag :arg2 + */ + public function modalIsShowingText($arg1, $arg2) + { + // get modal + $modal = $this->waitForNodeElement('.modal.transition.visible.active.front'); + if ($modal === null) { + throw new Exception('No modal found'); + } + // find text in modal + $text = $modal->find('xpath', '//' . $arg2 . '[text()="' . $arg1 . '"]'); + if (!$text || $text->getText() !== $arg1) { + throw new Exception('No such text in modal'); + } + } + + /** + * Get a node element by it's selector. + * Will try to get element for 20ms. + * Exemple: Use with a modal window where reloaded content + * will resize it's window thus making it not accessible at first. + */ + private function waitForNodeElement(string $selector, int $ms = 20): ?NodeElement + { + $counter = 0; + $element = null; + while ($counter < $ms) { + $element = $this->getSession()->getPage()->find('css', $selector); + if ($element === null) { + usleep(1000); + ++$counter; + } else { + break; + } + } + + return $element; + } + + /** + * @Then Active tab should be :arg1 + */ + public function activeTabShouldBe($arg1) + { + $tab = $this->getSession()->getPage()->find('css', '.ui.tabular.menu > .item.active'); + if ($tab->getText() !== $arg1) { + throw new Exception('Active tab is not ' . $arg1); + } + } + + /** + * @Then I hide js modal + * + * Hide js modal. + */ + public function iHideJsModal() + { + $this->getSession()->executeScript('$(".modal.active.front").modal("hide")'); + } + + /** + * @Then I scroll to top + */ + public function iScrollToTop() + { + $this->getSession()->executeScript('window.scrollTo(0,0)'); + } + + /** + * @Then Toast display should contains text :arg1 + */ + public function toastDisplayShouldContainText($arg1) + { + // get toast + $toast = $this->getSession()->getPage()->find('css', '.ui.toast-container'); + if ($toast === null) { + throw new Exception('No toast found'); + } + $content = $toast->find('css', '.content'); + if ($content === null) { + throw new Exception('No Content in Toast'); + } + // find text in toast + $text = $content->find('xpath', '//div'); + if (!$text || mb_strpos($text->getText(), $arg1) === false) { + throw new Exception('No such text in toast'); + } + } + + /** + * @Then I select value :arg1 in lookup :arg2 + * + * Select a value in a lookup control. + */ + public function iSelectValueInLookup($arg1, $arg2) + { + // get dropdown item from semantic ui which is direct parent of input html element + $inputElem = $this->getSession()->getPage()->find('css', 'input[name=' . $arg2 . ']'); + if ($inputElem === null) { + throw new Exception('Lookup element not found: ' . $arg2); + } + $lookupElem = $inputElem->getParent(); + + // open dropdown and wait till fully opened (just a click is not triggering it) + $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("show")'); + $this->jqueryWait('$("#' . $lookupElem->getAttribute('id') . '").hasClass("visible")'); + + // select value + $valueElem = $lookupElem->find('xpath', '//div[text()="' . $arg1 . '"]'); + if ($valueElem === null || $valueElem->getText() !== $arg1) { + throw new Exception('Value not found: ' . $arg1); + } + $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("set selected", ' . $valueElem->getAttribute('data-value') . ');'); + $this->jqueryWait(); + + // hide dropdown and wait till fully closed + $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("hide");'); + $this->jqueryWait(); + // for unknown reasons, dropdown very often remains visible in CI, so hide twice + $this->getSession()->executeScript('$("#' . $lookupElem->getAttribute('id') . '").dropdown("hide");'); + $this->jqueryWait('!$("#' . $lookupElem->getAttribute('id') . '").hasClass("visible")'); + } + + /** + * @Then I search grid for :arg1 + */ + public function iSearchGridFor($arg1) + { + $search = $this->getSession()->getPage()->find('css', 'input.atk-grid-search'); + if (!$search) { + throw new Exception('Unable to find search input.'); + } + + $search->setValue($arg1); + } + + /** + * @Then /^page url should contains \'([^\']*)\'$/ + */ + public function pageUrlShouldContains($text) + { + $url = $this->getSession()->getCurrentUrl(); + if (!strpos($url, $text)) { + throw new Exception('Text : "' . $text . '" not found in ' . $url); + } + } + + /** + * @Then /^I wait for the page to be loaded$/ + */ + public function waitForThePageToBeLoaded() + { + // This line in test-unit.yml is causing test to fail. Need to increase wait time to compensate. + // sed -i 's/usleep(100000)/usleep(5000)/' vendor/behat/mink-selenium2-driver/src/Selenium2Driver.php + usleep(500000); + $this->getSession()->wait(10000, "document.readyState === 'complete'"); + } + + /** + * @Then I click icon using css :arg1 + */ + public function iClickIconUsingCss($arg1) + { + $icon = $this->getSession()->getPage()->find('css', $arg1); + if (!$icon) { + throw new Exception('Unable to find search remove icon.'); + } + + $icon->click(); + } + + /** + * Generic ScopeBuilder rule with select operator and input value. + * + * @Then /^rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ + */ + public function scopeBuilderRule($name, $operator, $value) + { + $rule = $this->assertScopeBuilderRuleExist($name); + $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); + $this->assertInputValue($rule, $value); + } + + /** + * hasOne reference or enum type rule for ScopeBuilder. + * + * @Then /^reference rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ + */ + public function scopeBuilderReferenceRule($name, $operator, $value) + { + $rule = $this->assertScopeBuilderRuleExist($name); + $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); + $this->assertDropdownValue($rule, $value, '.vqb-rule-input .active.item'); + } + + /** + * hasOne select or enum type rule for ScopeBuilder. + * + * @Then /^select rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ + */ + public function scopeBuilderSelectRule($name, $operator, $value) + { + $rule = $this->assertScopeBuilderRuleExist($name); + $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); + $this->assertSelectedValue($rule, $value, '.vqb-rule-input select'); + } + + /** + * Date, Time or Datetime rule for ScopeBuilder. + * + * @Then /^date rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$/ + */ + public function scopeBuilderDateRule($name, $operator, $value) + { + $rule = $this->assertScopeBuilderRuleExist($name); + $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select'); + $this->assertInputValue($rule, $value, 'input.form-control'); + } + + /** + * Boolean type rule for ScopeBuilder. + * + * @Then /^bool rule "([^"]*)" has value "([^"]*)"$/ + */ + public function scopeBuilderBoolRule($name, $value) + { + $this->assertScopeBuilderRuleExist($name); + $idx = ($value === 'Yes') ? 0 : 1; + $isChecked = $this->getSession()->evaluateScript('return $(\'[data-name="' . $name . '"]\').find(\'input\')[' . $idx . '].checked'); + if (!$isChecked) { + throw new Exception('Radio value selected is not: ' . $value); + } + } + + /** + * @Then /^I check if text in "([^"]*)" match text in "([^"]*)"/ + */ + public function compareElementText($compareSelector, $compareToSelector) + { + $compareContainer = $this->getSession()->getPage()->find('css', $compareSelector); + if (!$compareContainer) { + throw new Exception('Unable to find compare container: ' . $compareSelector); + } + + $expectedText = $compareContainer->getText(); + + $compareToContainer = $this->getSession()->getPage()->find('css', $compareToSelector); + if (!$compareToContainer) { + throw new Exception('Unable to find compare to container: ' . $compareToSelector); + } + + $compareToText = $compareToContainer->getText(); + + if ($expectedText !== $compareToText) { + throw new Exception('Data word does not match: ' . $compareToText . ' expected: ' . $expectedText); + } + } + + /** + * @Then /^I check if input value for "([^"]*)" match text in "([^"]*)"$/ + */ + public function compareInputValueToElementText($inputName, $selector) + { + $expected = $this->getSession()->getPage()->find('css', $selector)->getText(); + $input = $this->getSession()->getPage()->find('css', 'input[name="' . $inputName . '"]'); + if (!$input) { + throw new Exception('Unable to find input name: ' . $inputName); + } + + if (preg_replace('~\s*~', '', $expected) !== preg_replace('~\s*~', '', $input->getValue())) { + throw new Exception('Input value does not match: ' . $input->getValue() . ' expected: ' . $expected); + } + } + + /** + * @Then /^text in container using \'([^\']*)\' should contains \'([^\']*)\'$/ + */ + public function textInContainerUsingShouldContains($containerCss, $text) + { + $container = $this->getSession()->getPage()->find('css', $containerCss); + if (!$container) { + throw new Exception('Unable to find container: ' . $containerCss); + } + + if (trim($container->getText()) !== $text) { + throw new Exception('Text not in container ' . $text . ' - ' . $container->getText()); + } + } + + /** + * Find a dropdown component within an html element + * and check if value is set in dropdown. + */ + private function assertDropdownValue(NodeElement $element, string $value, string $selector) + { + $dropdown = $element->find('css', $selector); + if (!$dropdown) { + throw new Exception('Dropdown input not found using selector: ' . $selector); + } + + $dropdownValue = $dropdown->getHtml(); + if ($dropdownValue !== $value) { + throw new Exception('Value: "' . $value . '" not set using selector: ' . $selector); + } + } + + /** + * Find a select input type within an html element + * and check if value is selected. + */ + private function assertSelectedValue(NodeElement $element, string $value, string $selector) + { + $select = $element->find('css', $selector); + if (!$select) { + throw new Exception('Select input not found using selector: ' . $selector); + } + $selectValue = $select->getValue(); + if ($selectValue !== $value) { + throw new Exception('Value: "' . $value . '" not set using selector: ' . $selector); + } + } + + /** + * Find an input within an html element and check + * if value is set. + */ + private function assertInputValue(NodeElement $element, string $value, string $selector = 'input') + { + $input = $element->find('css', $selector); + if (!$input) { + throw new Exception('Input not found in selector: ' . $selector); + } + $inputValue = $input->getValue(); + if ($inputValue !== $value) { + throw new Exception('Input value not is not: ' . $value); + } + } + + private function assertScopeBuilderRuleExist(string $ruleName): NodeElement + { + $rule = $this->getSession()->getPage()->find('css', '.vqb-rule[data-name=' . $ruleName . ']'); + if (!$rule) { + throw new Exception('Rule not found: ' . $ruleName); + } + + return $rule; + } + + /** + * Wait for an element, usually an auto trigger element, to show that loading has start" + * Example, when entering value in JsSearch for grid. We need to auto trigger to fire before + * doing waiting for callback. + * $arg1 should represent the element selector for jQuery. + * + * @Then I wait for loading to start in :arg1 + */ + public function iWaitForLoadingToStartIn($arg1) + { + $this->getSession()->wait(2000, '$("' . $arg1 . '").hasClass("loading")'); + } + + protected function getFinishedScript(): string + { + return 'document.readyState === \'complete\'' + . ' && typeof jQuery !== \'undefined\' && jQuery.active === 0' + . ' && typeof atk !== \'undefined\' && atk.vueService.areComponentsLoaded()'; + } + + /** + * Wait till jQuery AJAX request finished and no animation is perform. + */ + protected function jqueryWait(string $extraWaitCondition = 'true', $maxWaitdurationMs = 5000) + { + $finishedScript = '(' . $this->getFinishedScript() . ') && (' . $extraWaitCondition . ')'; + + $s = microtime(true); + $c = 0; + while (microtime(true) - $s <= $maxWaitdurationMs / 1000) { + $this->getSession()->wait($maxWaitdurationMs, $finishedScript); + usleep(10000); + if ($this->getSession()->evaluateScript($finishedScript)) { + if (++$c >= 2) { + return; + } + } else { + $c = 0; + usleep(50000); + } + } + + throw new Exception('jQuery did not finished within a time limit'); + } + + /** + * @Then /^the field "([^"]*)" should start with "([^"]*)"$/ + */ + public function theShouldStartWith($arg1, $arg2) + { + $field = $this->assertSession()->fieldExists($arg1); + + if (mb_strpos($field->getValue(), $arg2) === false) { + throw new Exception('Field value ' . $field->getValue() . ' does not start with ' . $arg2); + } + } +} diff --git a/tests-behat/bootstrap/ContextDump.php b/tests-behat/Bootstrap/ContextDump.php similarity index 94% rename from tests-behat/bootstrap/ContextDump.php rename to tests-behat/Bootstrap/ContextDump.php index 049df1d1..c3a73122 100644 --- a/tests-behat/bootstrap/ContextDump.php +++ b/tests-behat/Bootstrap/ContextDump.php @@ -13,7 +13,7 @@ class ContextDump extends Context { /** - * Dump current page data when step failed to allow easy debug on TravisCI. + * Dump current page data when step failed for CI. * * @AfterStep */ diff --git a/tests-behat/bootstrap/Context.php b/tests-behat/bootstrap/Context.php deleted file mode 100644 index 667765ee..00000000 --- a/tests-behat/bootstrap/Context.php +++ /dev/null @@ -1,84 +0,0 @@ -getMink()->getSession($name); - } - - /** - * @When I press button :arg1 - */ - public function iPressButton($arg1) - { - $button = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - // store button id. - $this->buttonId = $button->getAttribute('id'); - // fix "is out of bounds of viewport width and height" for Firefox - $button->focus(); - $button->click(); - } - - /** - * @Then I see button :arg1 - */ - public function iSeeButton($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - if ($element->getAttribute('style')) { - throw new \Exception("Element with text \"{$arg1}\" must be invisible"); - } - } - - /** - * @Then dump :arg1 - */ - public function dump($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - var_dump($element->getOuterHtml()); - } - - /** - * @Then I don't see button :arg1 - */ - public function iDontSeeButton($arg1) - { - $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]'); - if (strpos('display: none', $element->getAttribute('style')) !== false) { - throw new \Exception("Element with text \"{$arg1}\" must be invisible"); - } - } - - /** - * @Then Modal opens with text :arg1 - * - * Check if text is present in modal or dynamic modal. - */ - public function modalOpensWithText($arg1) - { - // get modal - $modal = $this->getSession()->getPage()->find('css', '.modal.transition.visible.active.front'); - if ($modal === null) { - throw new \Exception('No modal found'); - } - // find text in modal - $text = $modal->find('xpath', '//div[text()="' . $arg1 . '"]'); - if (!$text || $text->getText() !== $arg1) { - throw new \Exception('No such text in modal'); - } - } -} diff --git a/tests-behat/basic.feature b/tests-behat/login.feature similarity index 51% rename from tests-behat/basic.feature rename to tests-behat/login.feature index eac79c5a..bd959f55 100644 --- a/tests-behat/basic.feature +++ b/tests-behat/login.feature @@ -4,29 +4,29 @@ Feature: Login basic I need to authenticate to access the interface Scenario: - Given I am on "login.php" - Then I see button "Sign in" - And I should not see "You are authenticated" + Given I am on "form-login.php" + Then I should see "Sign in" + And I should not see "Currently logged in" Scenario: - Given I am on "login.php" - When I fill in "email" with "admin" + Given I am on "form-login.php" + And I fill in "email" with "admin" And I fill in "password" with "admin" And I press button "Sign in" - Then I should see "You are authenticated" + Then I should see "Currently logged in" Scenario: - Given I am on "login.php" - When I fill in "email" with "admin" + Given I am on "form-login.php" + And I fill in "email" with "admin" And I fill in "password" with "wrong" And I press button "Sign in" Then I should see "incorrect" - And I should not see "You are authenticated" + And I should not see "Currently logged in" Scenario: - Given I am on "login.php" + Given I am on "form-login.php" When I fill in "email" with "" And I fill in "password" with "admin" And I press button "Sign in" - Then I should see "incorrect" - And I should not see "You are authenticated" + Then I should see "Must not be empty" + And I should not see "Currently logged in" diff --git a/tests-behat/register.feature b/tests-behat/register.feature new file mode 100644 index 00000000..cb0bf109 --- /dev/null +++ b/tests-behat/register.feature @@ -0,0 +1,21 @@ +Feature: Register basic + check register + check unique register + + Scenario: + Given I am on "form-register.php" + And I fill in "name" with "admin" + And I fill in "email" with "admin@agiletoolkit.org" + And I fill in "password" with "admin" + And I fill in "password2" with "admin" + And I press button "Register" + Then I should see "Account has been created" + + Scenario: + Given I am on "form-register.php" + And I fill in "name" with "admin" + And I fill in "email" with "admin@agiletoolkit.org" + And I fill in "password" with "admin" + And I fill in "password2" with "admin" + And I press button "Register" + Then I should see "User with this email already exist" diff --git a/tests/Feature/UniqueFieldValueTest.php b/tests/Feature/UniqueFieldValueTest.php index 8ca4c99e..4debfb25 100644 --- a/tests/Feature/UniqueFieldValueTest.php +++ b/tests/Feature/UniqueFieldValueTest.php @@ -7,7 +7,7 @@ use Atk4\Data\Model; use Atk4\Data\ValidationException; use Atk4\Login\Feature\UniqueFieldValue; -use Atk4\Login\tests\Generic; +use Atk4\Login\Tests\Generic; class UniqueFieldValueTest extends Generic { diff --git a/tests/PasswordFieldTest.php b/tests/PasswordFieldTest.php index a18a1a7f..872427f5 100644 --- a/tests/PasswordFieldTest.php +++ b/tests/PasswordFieldTest.php @@ -29,8 +29,7 @@ public function testPasswordField() public function testPasswordPersistence() { - $a = []; - $p = new Persistence\Array_($a); + $p = new Persistence\Array_(); $m = new Model($p); $m->addField('p', [Password::class]); @@ -44,7 +43,7 @@ public function testPasswordPersistence() $m->save(); // stored encoded password - $enc = $this->getProtected($p, 'data')['data']->getRowById($m, 1)->getValue('p'); + $enc = $this->getProtected($p, 'data')['data'][1]['p']; //->getRowById($m, 1)->getValue('p'); $this->assertTrue(is_string($enc)); $this->assertNotSame('mypass', $enc); @@ -69,13 +68,12 @@ public function testPasswordPersistence() $this->assertTrue($m->getField('p')->verify('newpass')); // will have new hash - $this->assertNotSame($enc, $this->getProtected($p, 'data')['data']->getRowById($m, 1)->getValue('p')); + $this->assertNotSame($enc, $this->getProtected($p, 'data')['data'][1]['p']); //->getRowById($m, 1)->getValue('p')); } public function testCanNotCompareEmptyException() { - $a = []; - $p = new Persistence\Array_($a); + $p = new Persistence\Array_(); $m = new Model($p); $m->addField('p', [Password::class]);