Commit 15fdbee6 authored by Carsten  Rose's avatar Carsten Rose
Browse files

Merge branch 'selenium' into 'master'

Selenium + Docker

See merge request !163
parents 63583917 430f70a3
Pipeline #2040 passed with stages
in 2 minutes and 20 seconds
before_script:
- VERSION=`cat ./version`
- mkdir build || true
variables:
SELENIUM_LOGS_PATH: "/scratch/tmp/7/"
stages:
- before
- build
- selenium
documentation:
stage: before
......@@ -16,17 +21,48 @@ snapshot:
stage: build
except:
- tags
artifacts:
expire_in: 1 week
paths:
- build/
script:
- make VERSION=$VERSION phpunit_snapshot
- chmod a+r qfq_$VERSION_*.zip
- scp qfq_$VERSION_*.zip w16:qfq/snapshots/
- mv qfq_$VERSION_*.zip build/qfq.zip
release:
stage: build
only:
- tags
artifacts:
expire_in: 1 week
paths:
- build/
script:
- make VERSION=$VERSION phpunit_release
- chmod a+r qfq_$VERSION_*.zip
- scp qfq_$VERSION_*.zip w16:qfq/releases/
- mv qfq_$VERSION_*.zip build/qfq.zip
selenium:
stage: selenium
script:
- unzip -q build/qfq.zip -d qfq
- cd docker/
- ./run_qfq_docker.sh -no-deploy
- ./deploy_to_container.sh ../qfq
- ./run_selenium_tests_docker.sh
- echo "hello"
after_script:
# remove containers and move logs to persistent location
- cd docker; ./remove-containers.sh <<< "y"
- cd ..
- umask 002
- mkdir "$SELENIUM_LOGS_PATH/$CI_COMMIT_SHORT_SHA"
- cp extension/Tests/selenium/selenium_logs/* "$SELENIUM_LOGS_PATH/$CI_COMMIT_SHORT_SHA/"
- echo "Selenium Logs copied to $SELENIUM_LOGS_PATH/$CI_COMMIT_SHORT_SHA/"
......@@ -253,8 +253,8 @@ Setup a *report* to manage all *forms*:
10 {
# List of Forms: Do not show this list of forms if there is a form given by SIP.
# Table header.
sql = SELECT CONCAT('p:{{pageAlias:T}}&form=form') as _pagen, '#', 'Name', 'Title', 'Table', '' FROM (SELECT 1) AS fake WHERE '{{form:SE}}'=''
head = {{'b|p:id={{pageAlias:T}}&form=copyFormFromExt|t:Copy form from ExtForm' AS _link}}
sql = SELECT CONCAT('p:{{pageAlias:T}}&form=form|A:data-reference=newForm') as _pagen, '#', 'Name', 'Title', 'Table', '' FROM (SELECT 1) AS fake WHERE '{{form:SE}}'=''
head = {{'b|p:id={{pageAlias:T}}&form=copyFormFromExt|t:Copy form from ExtForm|A:data-reference=copyForm' AS _link}}
<table class="table table-hover qfq-table-50 tablesorter tablesorter-filter">
tail = </table>
rbeg = <thead><tr>
......@@ -264,9 +264,9 @@ Setup a *report* to manage all *forms*:
10 {
# All forms
sql = SELECT CONCAT('p:{{pageAlias:T}}&form=form&r=', f.id) as _pagee
sql = SELECT CONCAT('p:{{pageAlias:T}}&form=form&r=', f.id, '|A:data-reference=editForm', f.name) as _pagee
, f.id, f.name, f.title AS '_striptags', f.tableName
, CONCAT('U:form=form&r=', f.id) as _paged
, CONCAT('U:form=form&r=', f.id, '|A:data-reference=deletForm') as _paged
FROM Form AS f
ORDER BY f.name
rbeg = <tr>
......
......@@ -64,9 +64,7 @@ bootstrap: .npmpackages .plantuml_install .virtual_env
cd extension; composer update
basic: .npmpackages .virtual_env
npm update
grunt default
# IMPORTANT: install composer with no-dev flag for deployment!
cd extension; composer install --no-dev --optimize-autoloader; cd vendor/phpoffice/phpspreadsheet; rm -rf .github bin docs samples .g* .s* .t* C* c* m* p*
......@@ -74,9 +72,15 @@ basic: .npmpackages .virtual_env
wget --no-check-certificate -O support/plantuml/plantuml.jar 'https://downloads.sourceforge.net/project/plantuml/plantuml.jar'
touch $@
.npmpackages: package.json
.npmpackages:
npm ls -g grunt-cli 2>/dev/null || { echo "Please install grunt-cli npm package using 'npm install -g grunt-cli'" 1>&2 ; exit 1; }
npm install
# update npm at persistent location and copy node_modules (to speed up process)
mkdir -p /var/tmp/qfq/npm
/bin/cp package.json /var/tmp/qfq/npm/
cd /var/tmp/qfq/npm; npm update
/bin/cp -r /var/tmp/qfq/npm/node_modules ./
touch $@
.support:
......
Tested on Ubuntu 16.04 with Docker version 18.09.2
# Other Documentation
More documentation and docker tips are located in:
- https://wikiit.math.uzh.ch/it/bestpractice/Docker
- https://systemvcs.math.uzh.ch/megger/qfq_docker
# Build Images
1. Clone the docker image repository:
```git clone https://systemvcs.math.uzh.ch/megger/qfq_docker.git```
2. Build images as explained in the README.md of the image repository.
For the below scripts to work please name the images ```python-selenium``` and ```typo3-qfq``` respectively.
# Run and Interact with Containers
## Run qfq in docker container from scratch
1. clone qfq git project
2. change to project directory (qfq)
3. ```make bootstrap```
4. ``` cd docker```
5. ```./run_qfq_docker.sh```
6. open the newly generated file run_qfq_docker.output and copy the value of T3_PORT. In a browser go to 127.0.0.1:<T3_PORT>.
## Deploy qfq extension changes to running container
Assumes you have run qfq in docker as explained above.
1. change to docker directory
2. ```./deploy_to_container.sh```
## Dump QFQ and (truncated) Typo3 databases to docker directory
Assumes you have run qfq in docker as explained above.
1. again from the docker directory run
```./dump_databases.sh``` (this will overwrite the db_fixtrue_*.sql files)
## Run Phpmyadmin
ATTENTION: Use Firefox if Phpmyadmin login does not work in Chrome!
Assumes you have run qfq in docker as explained above.
1. change to docker directory and run ```./run_phpmyadmin.sh```
2. open the file run_qfq_docker.output and copy the value of PMA_PORT. In a browser (Firefox) go to 127.0.0.1:<PMA_PORT>.
## Run selenium tests on local machine (with visible browser)
Assumes you have run qfq in docker as explained above.
1. again from the docker directory run ```./run_selenium_tests_local.sh```
## Run a single selenium test file on local machine
Assumes you have run qfq in docker as explained above.
1. copy T3_PORT from docker/run_qfq_docker.output
2. Export variables (replace <T3_PORT>):
```export SELENIUM_URL="http://127.0.0.1:<T3_PORT>" SELENIUM_HEADLESS="no"```
3. in extension/Tests/selenium run
```python <selenium test file>.py```
## Run selenium tests in docker container (test execution not visible)
Assumes you have run qfq in docker as explained above.
1. from the docker directory run ```./run_selenium_tests_docker.sh```
## Permanently remove all above created containers and their data
(only removes the containers listed in run_qfq_docker.output)
1. from the docker directory run ```./remove-containers.sh```
# TROUBLE SHOOT
## WebDriverException: Message: 'chromedriver' executable needs to be in PATH
1. Download Chromedriver:
```wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip```
2. Unzip chromedriver to current working directory
```unzip /tmp/chromedriver.zip chromedriver```
3. export chromedriver path environment variable before running selenium tests locally
```export CHROMEDRIVER_PATH=<absolute path to chromedriver>/chromedriver```
\ No newline at end of file
......@@ -34,4 +34,4 @@ function addUsernameToContainerName()
local CONTAINER="$1"
local CONTAINER_NAME=$(getContainerName ${CONTAINER})
docker rename ${CONTAINER_NAME} ${USER}_${CONTAINER_NAME}
}
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
#!/bin/bash -ex
#
# First Argument: Path to extension folder
PATH_TO_EXTENSION=../extension
if [[ "$1" ]]; then
PATH_TO_EXTENSION=$1
fi
source run_qfq_docker.output
docker exec ${T3_CONTAINER} rm -rf /var/www/html/typo3conf/ext/qfq
docker cp ${PATH_TO_EXTENSION} ${T3_CONTAINER}:/var/www/html/typo3conf/ext/qfq
docker exec ${T3_CONTAINER} chown -R www-data:www-data /var/www/html/typo3conf/ext/qfq
# call the qfq website once, since it shows an error the first time it is called
docker exec ${T3_CONTAINER} curl 127.0.0.1:80 > /dev/null
\ No newline at end of file
#!/bin/bash -ex
source run_qfq_docker.output
MYSQL_ROOT_PASSWORD=crazyfish123
# qfq database dump
mysqldump -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 qfq_db > db_fixture_qfq.sql
# typo3 database dump, truncate unimportant tables
to_truncate="
TRUNCATE cache_md5params;
TRUNCATE cache_treelist;
TRUNCATE cf_cache_hash;
TRUNCATE cf_cache_hash_tags;
TRUNCATE cf_cache_imagesizes;
TRUNCATE cf_cache_imagesizes_tags;
TRUNCATE cf_cache_pages;
TRUNCATE cf_cache_pagesection;
TRUNCATE cf_cache_pagesection_tags;
TRUNCATE cf_cache_pages_tags;
TRUNCATE cf_cache_rootline;
TRUNCATE cf_cache_rootline_tags;
TRUNCATE sys_log;
TRUNCATE sys_news;
TRUNCATE sys_note;
TRUNCATE tx_extensionmanager_domain_model_extension;
"
mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 -D t3_db -e "$to_truncate"
mysqldump -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 t3_db > db_fixture_t3.sql
#!/bin/bash -e
source _helper_functions.sh
source run_qfq_docker.output
echo "The following containers and their data will be deleted completely:"
echo "$(getContainerName ${DB_CONTAINER}), $(getContainerName ${T3_CONTAINER}), $(getContainerName ${PMA_CONTAINER} 2>/dev/null || true)"
read -p "Are you really really really sure you want to do this? (y/n): " -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "removing..."
docker rm -fv ${DB_CONTAINER} || true
docker rm -fv ${T3_CONTAINER} || true
docker rm -fv ${PMA_CONTAINER} 2>/dev/null || true
rm run_qfq_docker.output
echo "done."
fi
#!/bin/bash -ex
source run_qfq_docker.output
source _helper_functions.sh
PMA_CONTAINER_NAME= # if empty choose random
PMA_PORT=0 # if set to 0 choose random
PMA_CONTAINER=$(docker run -d --name "${PMA_CONTAINER_NAME}" -p ${PMA_PORT}:80 --link ${DB_CONTAINER}:db phpmyadmin/phpmyadmin)
PMA_PORT="$(getHostPort 80 ${PMA_CONTAINER})"
addUsernameToContainerName ${PMA_CONTAINER}
echo "
PMA_CONTAINER=${PMA_CONTAINER}
PMA_PORT=${PMA_PORT}
" >> run_qfq_docker.output
########################################
## User Output ##
########################################
echo "Finished. Go to: http://localhost:${PMA_PORT}"
\ No newline at end of file
#!/bin/bash -ex
source _helper_functions.sh
########################################
## Config ##
########################################
#CONTAINER_TIMEOUT=60 # stop container after this timeout (does not work with gitlab ci)
### Config Database Container ###
DB_CONTAINER_NAME= # if empty choose random
DB_PORT=0 # if set to 0 choose random
MYSQL_ROOT_PASSWORD=crazyfish123
MYSQL_USER=typo3
MYSQL_PASSWORD=crazyfish123
QFQ_DATABASE=qfq_db
T3_DATABASE=t3_db
### Config Typo3 Container ###
T3_IMAGE=typo3-qfq
T3_CONTAINER_NAME= # if empty choose random
T3_PORT=0 # if set to 0 choose random
# T3_ADMINO_PASSWORD= # typo3 password for user admino (if not set, then default is used)
# T3_INSTALL_PASSWORD_HASH=<ReplaceWithHashGeneratedByTypo3InstallTool>
# T3_REPLACE_ENCRYPTION_KEY=yes # generate new key using /dev/urandom
########################################
## Pre Process ##
########################################
source run_qfq_docker.output && echo "File run_qfq_docker.output already exists. Delete it first. Abort." \
&& exit 1 || true
########################################
## DB Container ##
########################################
DB_CONTAINER=$(docker run -d --name "${DB_CONTAINER_NAME}" \
-p ${DB_PORT}:3306 \
-e MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} \
-e MYSQL_USER=${MYSQL_USER} \
-e MYSQL_PASSWORD=${MYSQL_PASSWORD} \
mariadb:latest \
--character-set-server=utf8 \
--collation-server=utf8_general_ci)
DB_PORT="$(getHostPort 3306 ${DB_CONTAINER})"
addUsernameToContainerName ${DB_CONTAINER}
echo "
DB_CONTAINER=${DB_CONTAINER}
DB_PORT=${DB_PORT}
" >> run_qfq_docker.output
if [[ "${CONTAINER_TIMEOUT}" ]]; then
removeContainerAfterTimeout ${CONTAINER_TIMEOUT} ${DB_CONTAINER}
fi
### import database files ###
# HACK: Have to retry until database is ready. Database loading could be done earlier in image build process.
# As described here: https://stackoverflow.com/a/49680802
# Instead of copying the sql files in the dockerfile we could mount them in a volume!
tries=0
until mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 \
-e "CREATE DATABASE ${T3_DATABASE}; CREATE DATABASE ${QFQ_DATABASE}"
do
tries=$((tries+1))
if [[ "${tries}" -gt 30 ]]; then
echo "Timeout: could not connect to database."
exit 1;
fi
echo "Try again"
sleep 1
done
mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 -D ${T3_DATABASE} < db_fixture_t3.sql
mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 -D ${QFQ_DATABASE} < db_fixture_qfq.sql
mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 \
-e "GRANT ALL PRIVILEGES ON *.* TO '${MYSQL_USER}'@'%';"
########################################
## T3 Container ##
########################################
T3_CONTAINER=$(docker run -d --name "${T3_CONTAINER_NAME}" -p ${T3_PORT}:80 --link ${DB_CONTAINER}:db ${T3_IMAGE})
T3_PORT="$(getHostPort 80 ${T3_CONTAINER})"
addUsernameToContainerName ${T3_CONTAINER}
echo "
T3_CONTAINER=${T3_CONTAINER}
T3_PORT=${T3_PORT}
" >> run_qfq_docker.output
if [[ "${CONTAINER_TIMEOUT}" ]]; then
removeContainerAfterTimeout ${CONTAINER_TIMEOUT} ${T3_CONTAINER}
fi
### Write config files ###
# QFQ config
echo "
<?php
return [
'DB_1_USER' => '${MYSQL_USER}',
'DB_1_SERVER' => 'db',
'DB_1_PASSWORD' => '${MYSQL_PASSWORD}',
'DB_1_NAME' => '${QFQ_DATABASE}',
];" > config.qfq.php
docker cp config.qfq.php ${T3_CONTAINER}:/var/www/html/typo3conf/config.qfq.php
rm config.qfq.php
docker exec ${T3_CONTAINER} chown www-data:www-data /var/www/html/typo3conf/config.qfq.php
# Typo3 config
docker exec ${T3_CONTAINER} sed -i -e "s/<MYSQL_USER>/${MYSQL_USER}/g" /var/www/html/typo3conf/LocalConfiguration.php
docker exec ${T3_CONTAINER} sed -i -e "s/<MYSQL_PASSWORD>/${MYSQL_PASSWORD}/g" /var/www/html/typo3conf/LocalConfiguration.php
docker exec ${T3_CONTAINER} sed -i -e "s/<T3_DATABASE>/${T3_DATABASE}/g" /var/www/html/typo3conf/LocalConfiguration.php
if [[ "${T3_INSTALL_PASSWORD_HASH}" ]]; then
current_hash='s/$pbkdf2-sha256$25000$1h4tjST5Wv.PyZcwfIIVvQ$kUl8homXlnaDohmt6ki0Vsji9tEaJ6tQ9vnymDCfYAY'
docker exec ${T3_CONTAINER} sed -i -e "s/${current_hash}/${T3_INSTALL_PASSWORD_HASH}/g" \
/var/www/html/typo3conf/LocalConfiguration.php
fi
if [[ "${T3_REPLACE_ENCRYPTION_KEY}" ]]; then
key=$(hexdump -n 48 -e '4/4 "%08X" 1 "\n"' /dev/urandom)
key=$(echo "${key}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
old_key=50c80989f47d1097a66ed9d584b935dca4ecf881ffaf814701572906cb6f0cd894d10da8859d1d9b2241e0fe0fb2692d
docker exec ${T3_CONTAINER} sed -i -e "s/${old_key}/${key}/g" /var/www/html/typo3conf/LocalConfiguration.php
fi
### Deploy qfq extension to container ###
# This is slow. Could also be done in docker image build process.
# Or could mount qfq as a volume to the typo3-qfq container. (might cause problems)
if ! [[ $1 == "-no-deploy" ]]; then
./deploy_to_container.sh
fi
########################################
## Post Process ##
########################################
### Set Typo3 Admin Pass ###
if [[ "${T3_ADMINO_PASSWORD}" ]]; then
T3_ADMINO_PASSWORD_HASH=$(curl http://127.0.0.1:${T3_PORT}/typo3conf/ext/qfq/Source/api/hashPassword.php?pw=${T3_ADMINO_PASSWORD})
mysql -u root --password=${MYSQL_ROOT_PASSWORD} --port=${DB_PORT} -h 127.0.0.1 -D ${T3_DATABASE} \
-e "UPDATE be_users SET password='${T3_ADMINO_PASSWORD_HASH}' WHERE username='admino';"
fi
########################################
## User Output ##
########################################
echo "Finished. Go to:"
echo "localhost:${T3_PORT}"
\ No newline at end of file
#!/bin/bash -ex
source _helper_functions.sh
source run_qfq_docker.output
SELENIUM_IMAGE=python-selenium
makePathExecutable "${PWD}/../" # needed for volume mounting
SELENIUM_CONTAINER=$(docker run \
--link ${T3_CONTAINER}:typo3container \
--volume="${PWD}/../":/workingdir \
--shm-size="2g" \
-e HOST_UID=${UID} \
${SELENIUM_IMAGE} \
"cd /workingdir/extension/Tests/selenium; python -m unittest discover")
#!/bin/bash -ex
source run_qfq_docker.output
# download chromedriver
if [ ! -f chromedriver ]; then
wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip
unzip /tmp/chromedriver.zip chromedriver
chmod +x chromedriver
fi
export CHROMEDRIVER_PATH="${PWD}/chromedriver"
# run tests
cd ../extension/Tests/selenium
export SELENIUM_URL="http://127.0.0.1:${T3_PORT}"
export SELENIUM_HEADLESS="no"
python -m unittest discover
\ No newline at end of file
<?php
namespace qfq;
use qfq;
require_once(__DIR__ . '/../core/typo3/Password.php');
echo Password::getHash($_GET["pw"]);
\ No newline at end of file
# -*- coding: utf-8 -*-
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import Select
from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException, WebDriverException
import unittest
import os
import sys
import time
import codecs
import re
import socket
def urlJoin(url_list):
return '/'.join(s.strip('/') for s in url_list)
def urlFromHostname(hostname, port=80):
try:
return 'http://' + socket.gethostbyname(hostname) + ':' + str(port)
except socket.gaierror as e:
return 'unknown'
class QfqSeleniumTestCase(unittest.TestCase):
"""initialize selenium and add custom commands and asserts"""
max_number_of_log_files_to_keep = 100
# These variables can be overwritten by environment variables of the same name
CHROMEDRIVER_PATH = "chromedriver"
SELENIUM_LOGS_PATH = os.getcwd()
SELENIUM_HEADLESS = 'yes' # set environment variable to 'no' to turn off
SELENIUM_URL = urlFromHostname('typo3container')
@classmethod
def setUpClass(cls):
"""executed by unittest once before any tests are executed"""
# read environment variables
cls.CHROMEDRIVER_PATH = os.environ.get('CHROMEDRIVER_PATH', cls.CHROMEDRIVER_PATH)
cls.SELENIUM_LOGS_PATH = os.environ.get('SELENIUM_LOGS_PATH', cls.SELENIUM_LOGS_PATH)
cls.SELENIUM_HEADLESS = os.environ.get('SELENIUM_HEADLESS', cls.SELENIUM_HEADLESS)
cls.SELENIUM_URL = os.environ.get('SELENIUM_URL', cls.SELENIUM_URL)
# setup log directory, delete very old log files
cls.selenium_logs_dir = 'selenium_logs'
cls.selenium_logs_dir_path = os.path.join(cls.SELENIUM_LOGS_PATH, cls.selenium_logs_dir)
if not os.path.exists(cls.selenium_logs_dir_path):
os.makedirs(cls.selenium_logs_dir_path)
existing_log_files = os.listdir(cls.selenium_logs_dir_path)
existing_log_files.sort()
log_files_to_delete = existing_log_files[:-cls.max_number_of_log_files_to_keep]
log_file_pattern = re.compile('[0-9]{8}-{1}[0-9]{6}.*')
for filename in log_files_to_delete:
if not log_file_pattern.match(filename):
print 'non log file in log directory found: ', filename
continue
file_path = os.path.join(cls.selenium_logs_dir_path, filename)
os.remove(file_path)
# initialize webdriver
chrome_options = Options()
if cls.SELENIUM_HEADLESS != 'no':
chrome_options.add_argument("--headless")
chrome_options.add_argument("--window-size=1920,1080")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--no-sandbox")
desired_capabilities = {'UNEXPECTED_ALERT_BEHAVIOUR': 'ignore'}
cls.driver = webdriver.Chrome(chrome_options=chrome_options,
executable_path=cls.CHROMEDRIVER_PATH,
desired_capabilities=desired_capabilities)
cls.driver.implicitly_wait(30)
@classmethod
def tearDownClass(cls):
"""executed by unittest after all test cases were executed"""
cls.driver.quit()
def tearDown(self):
"""executed by unittest after every single test case"""
# save website state on failure
if sys.exc_info()[0]:
filename = time.strftime("%Y%m%d-%H%M%S") + '_' + self._testMethodName
screenshot_file_path = self.qfq_save_screenshot(filename)
html_file_path = self.qfq_save_html(filename)
print 'Test failed.'
print 'Webpage screenshot saved to ' + screenshot_file_path
print 'Webpage Html saved to ' + html_file_path
print '!!! ATTENTION !!!: If you get the error "unexpected alert open",' \
+ 'there must be another error above which actually triggers the test failure.'
def _prepare_log_file_path(self, filename, suffix=''):
file_path = os.path.join(self.selenium_logs_dir_path, filename + suffix)
return file_path
def _retry_on_certain_exceptions(self, function, retries=10):
while retries >= 0:
try:
function()
break
except (StaleElementReferenceException, NoSuchElementException, WebDriverException):
retries -= 1