diff --git a/functional_tests/.gitignore b/functional_tests/.gitignore
new file mode 100644
index 000000000..c72dd6158
--- /dev/null
+++ b/functional_tests/.gitignore
@@ -0,0 +1,197 @@
+
+# Created by https://www.gitignore.io/api/vim,emacs,macos,python,vagrant
+
+### Emacs ###
+# -*- mode: gitignore; -*-
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
+
+# Org-mode
+.org-id-locations
+*_archive
+
+# flymake-mode
+*_flymake.*
+
+# eshell files
+/eshell/history
+/eshell/lastdir
+
+# elpa packages
+/elpa/
+
+# reftex files
+*.rel
+
+# AUCTeX auto folder
+/auto/
+
+# cask packages
+.cask/
+dist/
+
+# Flycheck
+flycheck_*.el
+
+# server auth directory
+/server/
+
+# projectiles files
+.projectile
+
+# directory configuration
+.dir-locals.el
+
+### macOS ###
+*.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# dotenv
+.env
+
+# virtualenv
+.venv
+venv/
+ENV/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+### Vagrant ###
+.vagrant/
+Vagrantfile
+
+### Vim ###
+# swap
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-v][a-z]
+[._]sw[a-p]
+# session
+Session.vim
+# temporary
+.netrwhist
+# auto-generated tag files
+tags
+
+# End of https://www.gitignore.io/api/vim,emacs,macos,python,vagrant
+
+test_plinth/
+geckodriver.log
\ No newline at end of file
diff --git a/functional_tests/README.md b/functional_tests/README.md
new file mode 100644
index 000000000..813b92143
--- /dev/null
+++ b/functional_tests/README.md
@@ -0,0 +1,61 @@
+# Install Dependencies
+
+```
+$ sudo apt install python3-pytest
+$ pip3 install splinter
+$ pip3 install pytest-splinter
+$ pip3 install pytest-bdd
+$ sudo apt install xvfb # optional, to avoid opening browser windows
+$ pip3 install pytest-xvfb # optional, to avoid opening browser windows
+```
+
+- Install the latest version of geckodriver.
+It's usually a single binary which you can place at /usr/local/bin/geckodriver
+
+- Install the latest version of Mozilla Firefox.
+Download and extract the latest version from the Firefox website and symlink the binary named `firefox` to /usr/local/bin.
+
+Geckodriver will then use whatever version of Firefox you symlink as /usr/local/bin/firefox.
+
+# Run FreedomBox Service
+
+*Warning*: Functional tests will change the configuration of the system
+ under test, including changing the hostname and users. Therefore you
+ should run the tests using FreedomBox running on a throw-away VM.
+
+The VM should have NAT port-forwarding enabled so that 4430 on the
+host forwards to 443 on the guest. The web interface of FreedomBox
+should be accessible from the host system at https://localhost:4430/.
+
+# Setup FreedomBox Service for tests
+
+Create a new user as follows:
+
+* Username: tester
+* Password: testingtesting
+
+This step is optional if a fresh install of Plinth is being
+tested. Functional tests will create the required user using FreedomBox's
+first boot process.
+
+# Run Functional Tests
+
+From the directory functional_tests, run
+
+```
+$ py.test
+```
+
+The full test suite can take a long time to run (over 15 minutes). You
+can also specify which tests to run, by tag or keyword:
+
+```
+$ py.test -k essential
+```
+
+If xvfb is installed and you still want to see browser windows, use the
+`--no-xvfb` command-line argument.
+
+```
+$ py.test --no-xvfb -k mediawiki
+```
diff --git a/functional_tests/config.ini b/functional_tests/config.ini
new file mode 100644
index 000000000..047801387
--- /dev/null
+++ b/functional_tests/config.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+url = https://localhost:4430
+username = tester
+password = testingtesting
diff --git a/functional_tests/features/anonymity_network.feature b/functional_tests/features/anonymity_network.feature
new file mode 100644
index 000000000..a4c3c6d21
--- /dev/null
+++ b/functional_tests/features/anonymity_network.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @tor
+Feature: Anonymity Network
+ Manage Tor configuration.
+
+Background:
+ Given I'm a logged in user
+ Given the tor application is installed
+
+Scenario: Enable tor application
+ Given the tor application is disabled
+ When I enable the tor application
+ Then the tor service should be running
+
+Scenario: Disable tor application
+ Given the tor application is enabled
+ When I disable the tor application
+ Then the tor service should not be running
diff --git a/functional_tests/features/bittorrent_client_deluge.feature b/functional_tests/features/bittorrent_client_deluge.feature
new file mode 100644
index 000000000..901cbcbd4
--- /dev/null
+++ b/functional_tests/features/bittorrent_client_deluge.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @deluge
+Feature: BitTorrent Client
+ Run the Deluge BitTorrent client.
+
+Background:
+ Given I'm a logged in user
+ Given the deluge application is installed
+
+Scenario: Enable deluge application
+ Given the deluge application is disabled
+ When I enable the deluge application
+ Then the deluge site should be available
+
+Scenario: Disable deluge application
+ Given the deluge application is enabled
+ When I disable the deluge application
+ Then the deluge site should not be available
diff --git a/functional_tests/features/bittorrent_client_transmission.feature b/functional_tests/features/bittorrent_client_transmission.feature
new file mode 100644
index 000000000..da9567382
--- /dev/null
+++ b/functional_tests/features/bittorrent_client_transmission.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @transmission
+Feature: BitTorrent Client
+ Run the Transmission BitTorrent client.
+
+Background:
+ Given I'm a logged in user
+ Given the transmission application is installed
+
+Scenario: Enable transmission application
+ Given the transmission application is disabled
+ When I enable the transmission application
+ Then the transmission site should be available
+
+Scenario: Disable transmission application
+ Given the transmission application is enabled
+ When I disable the transmission application
+ Then the transmission site should not be available
diff --git a/functional_tests/features/block_sandbox.feature b/functional_tests/features/block_sandbox.feature
new file mode 100644
index 000000000..b5244ff2a
--- /dev/null
+++ b/functional_tests/features/block_sandbox.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @minetest
+Feature: Block Sandbox
+ Run the Minetest server
+
+Background:
+ Given I'm a logged in user
+ Given the minetest application is installed
+
+Scenario: Enable minetest application
+ Given the minetest application is disabled
+ When I enable the minetest application
+ Then the minetest service should be running
+
+Scenario: Disable minetest application
+ Given the minetest application is enabled
+ When I disable the minetest application
+ Then the minetest service should not be running
diff --git a/functional_tests/features/calendar_and_addressbook.feature b/functional_tests/features/calendar_and_addressbook.feature
new file mode 100644
index 000000000..3c5fe1e0b
--- /dev/null
+++ b/functional_tests/features/calendar_and_addressbook.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @radicale
+Feature: Calendar and Addressbook
+ Configure CalDAV/CardDAV server.
+
+Background:
+ Given I'm a logged in user
+ Given the radicale application is installed
+
+Scenario: Enable radicale application
+ Given the radicale application is disabled
+ When I enable the radicale application
+ Then the radicale service should be running
+
+Scenario: Disable radicale application
+ Given the radicale application is enabled
+ When I disable the radicale application
+ Then the radicale service should not be running
diff --git a/functional_tests/features/chat_server.feature b/functional_tests/features/chat_server.feature
new file mode 100644
index 000000000..67612f29c
--- /dev/null
+++ b/functional_tests/features/chat_server.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @ejabberd
+Feature: Chat Server
+ Run ejabberd chat server.
+
+Background:
+ Given I'm a logged in user
+ Given the ejabberd application is installed
+
+Scenario: Enable ejabberd application
+ Given the ejabberd application is disabled
+ When I enable the ejabberd application
+ Then the ejabberd service should be running
+
+Scenario: Disable ejabberd application
+ Given the ejabberd application is enabled
+ When I disable the ejabberd application
+ Then the ejabberd service should not be running
diff --git a/functional_tests/features/collaborative_text_editor.feature b/functional_tests/features/collaborative_text_editor.feature
new file mode 100644
index 000000000..cb91fb224
--- /dev/null
+++ b/functional_tests/features/collaborative_text_editor.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @infinoted
+Feature: Collaborative Text Editor
+ Run Gobby Server - Infinoted
+
+Background:
+ Given I'm a logged in user
+ Given the infinoted application is installed
+
+Scenario: Enable infinoted application
+ Given the infinoted application is disabled
+ When I enable the infinoted application
+ Then the infinoted service should be running
+
+Scenario: Disable infinoted application
+ Given the infinoted application is enabled
+ When I disable the infinoted application
+ Then the infinoted service should not be running
diff --git a/functional_tests/features/configuration.feature b/functional_tests/features/configuration.feature
new file mode 100644
index 000000000..134972f79
--- /dev/null
+++ b/functional_tests/features/configuration.feature
@@ -0,0 +1,51 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@system @essential
+Feature: Configuration
+ Configure the system.
+
+Background:
+ Given I'm a logged in user
+
+Scenario: Change hostname
+ When I change the hostname to mybox
+ Then the hostname should be mybox
+
+Scenario: Change domain name
+ When I change the domain name to mydomain
+ Then the domain name should be mydomain
+
+Scenario Outline: Change language
+ When I change the language to
+ Then Plinth language should be
+
+ Examples:
+ | language |
+ | Danish |
+ | German |
+ | Spanish |
+ | French |
+ | Norwegian Bokmål |
+ | Dutch |
+ | Polish |
+ | Portuguese |
+ | Russian |
+ | Swedish |
+ | Telugu |
+ | Turkish |
+ | Simplified Chinese |
diff --git a/functional_tests/features/date_and_time.feature b/functional_tests/features/date_and_time.feature
new file mode 100644
index 000000000..2ff8e0624
--- /dev/null
+++ b/functional_tests/features/date_and_time.feature
@@ -0,0 +1,33 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@essential @date-and-time @system
+Feature: Date and Time
+ Configure time zone and network time service.
+
+Background:
+ Given I'm a logged in user
+
+Scenario: Enable network time application
+ Given the network time application is disabled
+ When I enable the network time application
+ Then the network time service should be running
+
+Scenario: Disable network time application
+ Given the network time application is enabled
+ When I disable the network time application
+ Then the network time service should not be running
diff --git a/functional_tests/features/email_client.feature b/functional_tests/features/email_client.feature
new file mode 100644
index 000000000..dae2c6212
--- /dev/null
+++ b/functional_tests/features/email_client.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @roundcube
+Feature: Email Client
+ Run webmail client.
+
+Background:
+ Given I'm a logged in user
+ Given the roundcube application is installed
+
+Scenario: Enable roundcube application
+ Given the roundcube application is disabled
+ When I enable the roundcube application
+ Then the roundcube site should be available
+
+Scenario: Disable roundcube application
+ Given the roundcube application is enabled
+ When I disable the roundcube application
+ Then the roundcube site should not be available
diff --git a/functional_tests/features/file_sharing.feature b/functional_tests/features/file_sharing.feature
new file mode 100644
index 000000000..9a5496636
--- /dev/null
+++ b/functional_tests/features/file_sharing.feature
@@ -0,0 +1,49 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @coquelicot
+Feature: File Sharing
+ Run Coquelicot File Sharing server.
+
+Background:
+ Given I'm a logged in user
+ Given the coquelicot application is installed
+
+Scenario: Enable coquelicot application
+ Given the coquelicot application is disabled
+ When I enable the coquelicot application
+ Then the coquelicot service should be running
+
+Scenario: Disable coquelicot application
+ Given the coquelicot application is enabled
+ When I disable the coquelicot application
+ Then the coquelicot service should not be running
+
+Scenario: Modify maximum upload size
+ Given the coquelicot application is enabled
+ When I modify the maximum file size of coquelicot to 256
+ Then the maximum file size of coquelicot should be 256
+
+Scenario: Modify upload password
+ Given the coquelicot application is enabled
+ When I modify the coquelicot upload password to whatever123
+ Then I should be able to login to coquelicot with password whatever123
+
+Scenario: Modify maximum upload size in disabled case
+ Given the coquelicot application is disabled
+ When I modify the maximum file size of coquelicot to 123
+ Then the coquelicot service should not be running
diff --git a/functional_tests/features/file_synchronization.feature b/functional_tests/features/file_synchronization.feature
new file mode 100644
index 000000000..e30902d33
--- /dev/null
+++ b/functional_tests/features/file_synchronization.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @syncthing
+Feature: File Synchronization
+ Run Syncthing File Synchronization server.
+
+Background:
+ Given I'm a logged in user
+ Given the syncthing application is installed
+
+Scenario: Enable syncthing application
+ Given the syncthing application is disabled
+ When I enable the syncthing application
+ Then the syncthing service should be running
+
+Scenario: Disable syncthing application
+ Given the syncthing application is enabled
+ When I disable the syncthing application
+ Then the syncthing service should not be running
diff --git a/functional_tests/features/irc_client.feature b/functional_tests/features/irc_client.feature
new file mode 100644
index 000000000..b1ce7c732
--- /dev/null
+++ b/functional_tests/features/irc_client.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @quassel
+Feature: IRC Client
+ Run Quassel core.
+
+Background:
+ Given I'm a logged in user
+ Given the quassel application is installed
+
+Scenario: Enable quassel application
+ Given the quassel application is disabled
+ When I enable the quassel application
+ Then the quassel service should be running
+
+Scenario: Disable quassel application
+ Given the quassel application is enabled
+ When I disable the quassel application
+ Then the quassel service should not be running
diff --git a/functional_tests/features/news_feed_reader.feature b/functional_tests/features/news_feed_reader.feature
new file mode 100644
index 000000000..f021b7de6
--- /dev/null
+++ b/functional_tests/features/news_feed_reader.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @ttrss
+Feature: News Feed Reader
+ Run TT-RSS News Feed Reader.
+
+Background:
+ Given I'm a logged in user
+ Given the ttrss application is installed
+
+Scenario: Enable ttrss application
+ Given the ttrss application is disabled
+ When I enable the ttrss application
+ Then the ttrss service should be running
+
+Scenario: Disable ttrss application
+ Given the ttrss application is enabled
+ When I disable the ttrss application
+ Then the ttrss service should not be running
diff --git a/functional_tests/features/server_administration.feature b/functional_tests/features/server_administration.feature
new file mode 100644
index 000000000..48cb0a03d
--- /dev/null
+++ b/functional_tests/features/server_administration.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@system
+Feature: Server Administration
+ Run server administration application - Cockpit.
+
+Background:
+ Given I'm a logged in user
+ Given the cockpit application is installed
+
+Scenario: Enable cockpit application
+ Given the cockpit application is disabled
+ When I enable the cockpit application
+ Then the cockpit site should be available
+
+Scenario: Disable cockpit application
+ Given the cockpit application is enabled
+ When I disable the cockpit application
+ Then the cockpit site should not be available
diff --git a/functional_tests/features/service_discovery.feature b/functional_tests/features/service_discovery.feature
new file mode 100644
index 000000000..ef0cec088
--- /dev/null
+++ b/functional_tests/features/service_discovery.feature
@@ -0,0 +1,33 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@system @essential
+Feature: Service Discovery
+ Configure service discovery.
+
+Background:
+ Given I'm a logged in user
+
+Scenario: Enable service discovery application
+ Given the service discovery application is disabled
+ When I enable the service discovery application
+ Then the service discovery service should be running
+
+Scenario: Disable service discovery application
+ Given the service discovery application is enabled
+ When I disable the service discovery application
+ Then the service discovery service should not be running
diff --git a/functional_tests/features/single_sign_on.feature b/functional_tests/features/single_sign_on.feature
new file mode 100644
index 000000000..6ffbf9293
--- /dev/null
+++ b/functional_tests/features/single_sign_on.feature
@@ -0,0 +1,35 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@sso @essential @system
+Feature: Single Sign On
+ Test Single Sign On features.
+
+Background:
+ Given I'm a logged in user
+ Given the syncthing application is installed
+ Given the syncthing application is enabled
+
+
+Scenario: Logged out Plinth user cannot access Syncthing web interface
+ Given I'm a logged out user
+ When I access syncthing application
+ Then I should be prompted for login
+
+Scenario: Logged in Plinth user can access Syncthing web interface
+ When I access syncthing application
+ Then the syncthing site should be available
diff --git a/functional_tests/features/sip_server.feature b/functional_tests/features/sip_server.feature
new file mode 100644
index 000000000..6fbc43a7d
--- /dev/null
+++ b/functional_tests/features/sip_server.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @sip
+Feature: SIP Server
+ Make audio and video calls.
+
+Background:
+ Given I'm a logged in user
+ Given the repro application is installed
+
+Scenario: Enable repro application
+ Given the repro application is disabled
+ When I enable the repro application
+ Then the repro service should be running
+
+Scenario: Disable repro application
+ Given the repro application is enabled
+ When I disable the repro application
+ Then the repro service should not be running
diff --git a/functional_tests/features/socks5_proxy.feature b/functional_tests/features/socks5_proxy.feature
new file mode 100644
index 000000000..6cfa0dbff
--- /dev/null
+++ b/functional_tests/features/socks5_proxy.feature
@@ -0,0 +1,36 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @shadowsocks
+Feature: Socks5 Proxy
+ Run the Shadowsocks Socks5 proxy client.
+
+Background:
+ Given I'm a logged in user
+ Given the shadowsocks application is installed
+ Given the shadowsocks application is configured
+
+Scenario: Enable shadowsocks application
+ Given the shadowsocks application is disabled
+ When I enable the shadowsocks application
+ Then the shadowsocks service should be running
+
+Scenario: Disable shadowsocks application
+ Given the shadowsocks application is enabled
+ When I disable the shadowsocks application
+ Then the shadowsocks service should not be running
+
diff --git a/functional_tests/features/storage_snapshots.feature b/functional_tests/features/storage_snapshots.feature
new file mode 100644
index 000000000..2e98d6006
--- /dev/null
+++ b/functional_tests/features/storage_snapshots.feature
@@ -0,0 +1,29 @@
+#
+# This file is part of Plinth-tester.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@system @snapshots
+Feature: Storage Snapshots
+ Run storage snapshots application - Snapper.
+
+Background:
+ Given I'm a logged in user
+ Given the snapshot application is installed
+
+Scenario: Create a snapshot
+ Given the list of snapshots is empty
+ When I manually create a snapshot
+ Then there should be 1 snapshot in the list
diff --git a/functional_tests/features/users_and_groups.feature b/functional_tests/features/users_and_groups.feature
new file mode 100644
index 000000000..e7b56ac4a
--- /dev/null
+++ b/functional_tests/features/users_and_groups.feature
@@ -0,0 +1,40 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@system @essential @users-groups
+Feature: Users and Groups
+ Manage users and groups.
+
+Background:
+ Given I'm a logged in user
+
+Scenario: Create user
+ Given the user alice doesn't exist
+ When I create a user named alice with password secret123
+ Then alice should be listed as a user
+
+Scenario: Rename user
+ Given the user alice exists
+ Given the user bob doesn't exist
+ When I rename the user alice to bob
+ Then alice should not be listed as a user
+ Then bob should be listed as a user
+
+Scenario: Delete user
+ Given the user alice exists
+ When I delete the user alice
+ Then alice should not be listed as a user
diff --git a/functional_tests/features/voice_chat.feature b/functional_tests/features/voice_chat.feature
new file mode 100644
index 000000000..2a86d27b4
--- /dev/null
+++ b/functional_tests/features/voice_chat.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @mumble
+Feature: Voice Chat
+ Run Mumble voice chat server.
+
+Background:
+ Given I'm a logged in user
+ Given the mumble application is installed
+
+Scenario: Enable mumble application
+ Given the mumble application is disabled
+ When I enable the mumble application
+ Then the mumble service should be running
+
+Scenario: Disable mumble application
+ Given the mumble application is enabled
+ When I disable the mumble application
+ Then the mumble service should not be running
diff --git a/functional_tests/features/voip_and_chat_server.feature b/functional_tests/features/voip_and_chat_server.feature
new file mode 100644
index 000000000..7d45bb710
--- /dev/null
+++ b/functional_tests/features/voip_and_chat_server.feature
@@ -0,0 +1,36 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @matrixsynapse
+Feature: VoIP and Chat Server
+ Run Matrix Synapse server
+
+Background:
+ Given I'm a logged in user
+ Given the domain name is set to mydomain
+ Given the matrixsynapse application is installed
+ Given the domain name for matrixsynapse is set to mydomain
+
+Scenario: Enable matrixsynapse application
+ Given the matrixsynapse application is disabled
+ When I enable the matrixsynapse application
+ Then the matrixsynapse service should be running
+
+Scenario: Disable matrixsynapse application
+ Given the matrixsynapse application is enabled
+ When I disable the matrixsynapse application
+ Then the matrixsynapse service should not be running
diff --git a/functional_tests/features/web_proxy.feature b/functional_tests/features/web_proxy.feature
new file mode 100644
index 000000000..d9808a3ab
--- /dev/null
+++ b/functional_tests/features/web_proxy.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @privoxy
+Feature: Web Proxy
+ Proxy web connections for enhanced privacy.
+
+Background:
+ Given I'm a logged in user
+ Given the privoxy application is installed
+
+Scenario: Enable privoxy application
+ Given the privoxy application is disabled
+ When I enable the privoxy application
+ Then the privoxy service should be running
+
+Scenario: Disable privoxy application
+ Given the privoxy application is enabled
+ When I disable the privoxy application
+ Then the privoxy service should not be running
diff --git a/functional_tests/features/wiki_and_blog.feature b/functional_tests/features/wiki_and_blog.feature
new file mode 100644
index 000000000..57f4d5e37
--- /dev/null
+++ b/functional_tests/features/wiki_and_blog.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @ikiwiki
+Feature: Wiki and Blog
+ Manage wikis and blogs.
+
+Background:
+ Given I'm a logged in user
+ Given the wiki application is installed
+
+Scenario: Enable wiki application
+ Given the wiki application is disabled
+ When I enable the wiki application
+ Then the wiki site should be available
+
+Scenario: Disable wiki application
+ Given the wiki application is enabled
+ When I disable the wiki application
+ Then the wiki site should not be available
diff --git a/functional_tests/features/wiki_engine.feature b/functional_tests/features/wiki_engine.feature
new file mode 100644
index 000000000..331fd1769
--- /dev/null
+++ b/functional_tests/features/wiki_engine.feature
@@ -0,0 +1,34 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @mediawiki
+Feature: Wiki Engine
+ Manage wikis, multimedia and more.
+
+Background:
+ Given I'm a logged in user
+ Given the mediawiki application is installed
+
+Scenario: Enable mediawiki application
+ Given the mediawiki application is disabled
+ When I enable the mediawiki application
+ Then the mediawiki site should be available
+
+Scenario: Disable mediawiki application
+ Given the mediawiki application is enabled
+ When I disable the mediawiki application
+ Then the mediawiki site should not be available
diff --git a/functional_tests/features/xmpp_client.feature b/functional_tests/features/xmpp_client.feature
new file mode 100644
index 000000000..b26a46d3c
--- /dev/null
+++ b/functional_tests/features/xmpp_client.feature
@@ -0,0 +1,27 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+@apps @jsxc
+Feature: XMPP Client
+ Run the JSXC XMPP client.
+
+Background:
+ Given I'm a logged in user
+
+Scenario: Install jsxc application
+ Given the jsxc application is installed
+ Then the jsxc site should be available
diff --git a/functional_tests/pytest.ini b/functional_tests/pytest.ini
new file mode 100644
index 000000000..e69de29bb
diff --git a/functional_tests/step_definitions/__init__.py b/functional_tests/step_definitions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/functional_tests/step_definitions/application.py b/functional_tests/step_definitions/application.py
new file mode 100644
index 000000000..af2650622
--- /dev/null
+++ b/functional_tests/step_definitions/application.py
@@ -0,0 +1,113 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import given, parsers, then, when
+
+from support import application
+
+
+@given(parsers.parse('the {app_name:w} application is installed'))
+def application_is_installed(browser, app_name):
+ application.install(browser, app_name)
+
+
+@given(parsers.parse('the {app_name:w} application is enabled'))
+def application_is_enabled(browser, app_name):
+ application.enable(browser, app_name)
+
+
+@given(parsers.parse('the {app_name:w} application is disabled'))
+def application_is_disabled(browser, app_name):
+ application.disable(browser, app_name)
+
+
+@given(parsers.parse('the network time application is enabled'))
+def ntp_is_enabled(browser):
+ application.enable(browser, 'ntp')
+
+
+@given(parsers.parse('the network time application is disabled'))
+def ntp_is_disabled(browser):
+ application.disable(browser, 'ntp')
+
+
+@given(parsers.parse('the service discovery application is enabled'))
+def avahi_is_enabled(browser):
+ application.enable(browser, 'avahi')
+
+
+@given(parsers.parse('the service discovery application is disabled'))
+def avahi_is_disabled(browser):
+ application.disable(browser, 'avahi')
+
+
+@when(parsers.parse('I enable the {app_name:w} application'))
+def enable_application(browser, app_name):
+ application.enable(browser, app_name)
+
+
+@when(parsers.parse('I disable the {app_name:w} application'))
+def disable_application(browser, app_name):
+ application.disable(browser, app_name)
+
+
+@when(parsers.parse('I enable the network time application'))
+def enable_ntp(browser):
+ application.enable(browser, 'ntp')
+
+
+@when(parsers.parse('I disable the network time application'))
+def disable_ntp(browser):
+ application.disable(browser, 'ntp')
+
+
+@when(parsers.parse('I enable the service discovery application'))
+def enable_avahi(browser):
+ application.enable(browser, 'avahi')
+
+
+@when(parsers.parse('I disable the service discovery application'))
+def disable_avahi(browser):
+ application.disable(browser, 'avahi')
+
+
+@given(
+ parsers.parse('the domain name for {app_name:w} is set to {domain_name:w}')
+)
+def select_domain_name(browser, app_name, domain_name):
+ application.select_domain_name(browser, app_name, domain_name)
+
+
+@given('the shadowsocks application is configured')
+def configure_shadowsocks(browser):
+ application.configure_shadowsocks(browser)
+
+
+@when(
+ parsers.parse('I modify the maximum file size of coquelicot to {size:d}'))
+def modify_max_file_size(browser, size):
+ application.modify_max_file_size(browser, size)
+
+
+@then(parsers.parse('the maximum file size of coquelicot should be {size:d}'))
+def assert_max_file_size(browser, size):
+ assert application.get_max_file_size(browser) == size
+
+
+@when(parsers.parse('I modify the coquelicot upload password to {password:w}'))
+def modify_upload_password(browser, password):
+ application.modify_upload_password(browser, password)
diff --git a/functional_tests/step_definitions/interface.py b/functional_tests/step_definitions/interface.py
new file mode 100644
index 000000000..69a2c3c46
--- /dev/null
+++ b/functional_tests/step_definitions/interface.py
@@ -0,0 +1,84 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import given, parsers, then, when
+
+from support import config, interface
+
+
+default_url = config['DEFAULT']['url']
+
+
+@given("I'm a logged in user")
+def logged_in_user(browser):
+ interface.login(browser, default_url, config['DEFAULT']['username'],
+ config['DEFAULT']['password'])
+
+
+@given("I'm a logged out user")
+def logged_out_user(browser):
+ browser.visit(default_url + '/plinth/accounts/logout/')
+
+
+@then(parsers.parse('I should be prompted for login'))
+def prompted_for_login(browser):
+ assert interface.is_login_prompt(browser)
+
+
+@given(parsers.parse("the user {name:w} doesn't exist"))
+def new_user_does_not_exist(browser, name):
+ interface.nav_to_module(browser, 'users')
+ delete_link = browser.find_link_by_href(
+ '/plinth/sys/users/' + name + '/delete/')
+ if delete_link:
+ delete_link.first.click()
+ browser.find_by_value('Delete ' + name).click()
+
+
+@given(parsers.parse('the user {name:w} exists'))
+def test_user_exists(browser, name):
+ interface.nav_to_module(browser, 'users')
+ user_link = browser.find_link_by_href(
+ '/plinth/sys/users/' + name + '/edit/')
+ if not user_link:
+ create_user(browser, name, 'secret123')
+
+
+@when(
+ parsers.parse('I create a user named {name:w} with password {password:w}'))
+def create_user(browser, name, password):
+ interface.create_user(browser, name, password)
+
+
+@when(parsers.parse('I rename the user {old_name:w} to {new_name:w}'))
+def rename_user(browser, old_name, new_name):
+ interface.rename_user(browser, old_name, new_name)
+
+
+@when(parsers.parse('I delete the user {name:w}'))
+def delete_user(browser, name):
+ interface.delete_user(browser, name)
+
+
+@then(parsers.parse('{name:w} should be listed as a user'))
+def new_user_is_listed(browser, name):
+ assert interface.is_user(browser, name)
+
+
+@then(parsers.parse('{name:w} should not be listed as a user'))
+def new_user_is_not_listed(browser, name):
+ assert not interface.is_user(browser, name)
diff --git a/functional_tests/step_definitions/service.py b/functional_tests/step_definitions/service.py
new file mode 100644
index 000000000..5729dd67a
--- /dev/null
+++ b/functional_tests/step_definitions/service.py
@@ -0,0 +1,51 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import parsers, then
+
+from support import service
+from support.service import eventually
+
+
+@then(parsers.parse('the {service_name:w} service should be running'))
+def service_should_be_running(browser, service_name):
+ assert eventually(service.is_running, args=[browser, service_name])
+
+
+@then(parsers.parse('the {service_name:w} service should not be running'))
+def service_should_not_be_running(browser, service_name):
+ assert eventually(service.is_not_running, args=[browser, service_name])
+
+
+@then(parsers.parse('the network time service should be running'))
+def ntp_should_be_running(browser):
+ assert service.is_running(browser, 'ntp')
+
+
+@then(parsers.parse('the network time service should not be running'))
+def ntp_should_not_be_running(browser):
+ assert not service.is_running(browser, 'ntp')
+
+
+@then(parsers.parse('the service discovery service should be running'))
+def avahi_should_be_running(browser):
+ assert service.is_running(browser, 'avahi')
+
+
+@then(parsers.parse('the service discovery service should not be running'))
+def avahi_should_not_be_running(browser):
+ assert not service.is_running(browser, 'avahi')
diff --git a/functional_tests/step_definitions/site.py b/functional_tests/step_definitions/site.py
new file mode 100644
index 000000000..e179be40b
--- /dev/null
+++ b/functional_tests/step_definitions/site.py
@@ -0,0 +1,42 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import parsers, then, when
+
+from support import site
+
+
+@then(parsers.parse('the {site_name:w} site should be available'))
+def site_should_be_available(browser, site_name):
+ assert site.is_available(browser, site_name)
+
+
+@then(parsers.parse('the {site_name:w} site should not be available'))
+def site_should_not_be_available(browser, site_name):
+ assert not site.is_available(browser, site_name)
+
+
+@when(parsers.parse('I access {app_name:w} application'))
+def access_application(browser, app_name):
+ site.access_url(browser, app_name)
+
+
+@then(
+ parsers.parse(
+ 'I should be able to login to coquelicot with password {password:w}'))
+def verify_upload_password(browser, password):
+ site.verify_coquelicot_upload_password(browser, password)
diff --git a/functional_tests/step_definitions/system.py b/functional_tests/step_definitions/system.py
new file mode 100644
index 000000000..3d7b108ba
--- /dev/null
+++ b/functional_tests/step_definitions/system.py
@@ -0,0 +1,87 @@
+#
+# This file is part of Plinth-tester.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import given, parsers, then, when
+
+from support import system
+
+language_codes = {
+ 'Danish': 'da',
+ 'German': 'de',
+ 'Spanish': 'es',
+ 'French': 'fr',
+ 'Norwegian Bokmål': 'nb',
+ 'Dutch': 'nl',
+ 'Polish': 'pl',
+ 'Portuguese': 'pt',
+ 'Russian': 'ru',
+ 'Swedish': 'sv',
+ 'Telugu': 'te',
+ 'Turkish': 'tr',
+ 'Simplified Chinese': 'zh-hans',
+}
+
+
+@given(parsers.parse('the domain name is set to {domain:w}'))
+def set_domain_name(browser, domain):
+ system.set_domain_name(browser, domain)
+
+
+@when(parsers.parse('I change the hostname to {hostname:w}'))
+def change_hostname_to(browser, hostname):
+ system.set_hostname(browser, hostname)
+
+
+@when(parsers.parse('I change the domain name to {domain:w}'))
+def change_domain_name_to(browser, domain):
+ system.set_domain_name(browser, domain)
+
+
+@when('I change the language to ')
+def change_language(browser, language):
+ system.set_language(browser, language_codes[language])
+
+
+@then(parsers.parse('the hostname should be {hostname:w}'))
+def hostname_should_be(browser, hostname):
+ assert system.get_hostname(browser) == hostname
+
+
+@then(parsers.parse('the domain name should be {domain:w}'))
+def domain_name_should_be(browser, domain):
+ assert system.get_domain_name(browser) == domain
+
+
+@then('Plinth language should be ')
+def plinth_language_should_be(browser, language):
+ assert system.check_language(browser, language_codes[language])
+
+
+@given('the list of snapshots is empty')
+def empty_snapshots_list(browser):
+ system.delete_all_snapshots(browser)
+
+
+@when('I manually create a snapshot')
+def create_snapshot(browser):
+ system.create_snapshot(browser)
+
+
+@then(parsers.parse('there should be {count:d} snapshot in the list'))
+def verify_snapshot_count(browser, count):
+ num_snapshots = system.get_snapshot_count(browser)
+ assert num_snapshots == count
diff --git a/functional_tests/support/__init__.py b/functional_tests/support/__init__.py
new file mode 100644
index 000000000..b179d0577
--- /dev/null
+++ b/functional_tests/support/__init__.py
@@ -0,0 +1,25 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+import configparser
+import os
+
+config = configparser.ConfigParser()
+config.read('config.ini')
+
+config['DEFAULT']['url'] = os.environ.get('FREEDOMBOX_URL',
+ config['DEFAULT']['url'])
diff --git a/functional_tests/support/application.py b/functional_tests/support/application.py
new file mode 100644
index 000000000..5e06385f8
--- /dev/null
+++ b/functional_tests/support/application.py
@@ -0,0 +1,130 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from time import sleep
+
+from support import config, interface
+from support.service import eventually
+
+# unlisted apps just use the app_name as module name
+app_module = {
+ 'ntp': 'datetime',
+ 'wiki': 'ikiwiki',
+ 'tt-rss': 'ttrss',
+}
+
+app_checkbox_id = {
+ 'tor': 'id_tor-enabled',
+}
+
+app_config_updating_text = {
+ 'tor': 'Tor configuration is being updated',
+}
+
+default_url = config['DEFAULT']['url']
+
+
+def get_app_module(app_name):
+ module = app_name
+ if app_name in app_module:
+ module = app_module[app_name]
+ return module
+
+
+def get_app_checkbox_id(app_name):
+ checkbox_id = 'id_is_enabled'
+ if app_name in app_checkbox_id:
+ checkbox_id = app_checkbox_id[app_name]
+ return checkbox_id
+
+
+def install(browser, app_name):
+ interface.nav_to_module(browser, get_app_module(app_name))
+ install = browser.find_by_value('Install')
+ if install:
+ install.click()
+ while browser.is_text_present('Installing') \
+ or browser.is_text_present('Performing post-install operation'):
+ sleep(1)
+ sleep(2)
+
+
+def _change_status(browser, app_name, change_status_to='enabled'):
+ interface.nav_to_module(browser, get_app_module(app_name))
+ checkbox = browser.find_by_id(get_app_checkbox_id(app_name))
+ checkbox.check() if change_status_to == 'enabled' else checkbox.uncheck()
+ browser.find_by_value('Update setup').click()
+ if app_name == app_config_updating_text:
+ wait_for_config_update(browser, app_name)
+
+
+def enable(browser, app_name):
+ _change_status(browser, app_name, 'enabled')
+
+
+def disable(browser, app_name):
+ _change_status(browser, app_name, 'disabled')
+
+
+def wait_for_config_update(browser, app_name):
+ while browser.is_text_present(app_config_updating_text['tor']):
+ sleep(1)
+
+
+def select_domain_name(browser, app_name, domain_name):
+ browser.visit('{}/plinth/apps/{}/setup/'.format(default_url, app_name))
+ drop_down = browser.find_by_id('id_domain_name')
+ drop_down.select(domain_name)
+ browser.find_by_value('Update setup').click()
+
+
+def configure_shadowsocks(browser):
+ """Configure shadowsocks client with some fake server details"""
+ browser.visit('{}/plinth/apps/shadowsocks/'.format(default_url))
+ browser.find_by_id('id_server').fill('some.shadow.tunnel')
+ browser.find_by_id('id_password').fill('fakepassword')
+ browser.find_by_value('Update setup').click()
+
+
+def modify_max_file_size(browser, size):
+ """Change the maximum file size of coquelicot to the given value"""
+ browser.visit('{}/plinth/apps/coquelicot/'.format(default_url))
+ browser.find_by_id('id_max_file_size').fill(size)
+ browser.find_by_value('Update setup').click()
+ # Wait for the service to restart after updating maximum file size
+ eventually(message_or_setting_unchanged,
+ args=[browser, 'Maximum file size updated'])
+
+
+def message_or_setting_unchanged(browser, message):
+ return browser.is_text_present(message) or browser.is_text_present(
+ 'Setting unchanged')
+
+
+def get_max_file_size(browser):
+ """Get the maximum file size of coquelicot"""
+ browser.visit('{}/plinth/apps/coquelicot/'.format(default_url))
+ return int(browser.find_by_id('id_max_file_size').value)
+
+
+def modify_upload_password(browser, password):
+ """Change the upload password for coquelicot to the given value"""
+ browser.visit('{}/plinth/apps/coquelicot/'.format(default_url))
+ browser.find_by_id('id_upload_password').fill(password)
+ browser.find_by_value('Update setup').click()
+ # Wait for the service to restart after updating password
+ eventually(browser.is_text_present, args=['Upload password updated'])
diff --git a/functional_tests/support/interface.py b/functional_tests/support/interface.py
new file mode 100644
index 000000000..9051b8d4f
--- /dev/null
+++ b/functional_tests/support/interface.py
@@ -0,0 +1,101 @@
+#
+# This file is part of Plinth-tester.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+from support import config
+
+sys_modules = [
+ 'avahi', 'cockpit', 'config', 'datetime', 'diagnostics', 'firewall',
+ 'letsencrypt', 'monkeysphere', 'names', 'networks', 'power', 'snapshot',
+ 'upgrades', 'users'
+]
+
+default_url = config['DEFAULT']['url']
+
+
+def login(browser, url, username, password):
+ browser.visit(url)
+ if browser.find_by_id('logout-nojs'):
+ return # already logged in
+
+ login_button = browser.find_link_by_href('/plinth/accounts/login/')
+ if login_button:
+ login_button.first.click()
+ login_submit = browser.find_by_value('Login')
+ if login_button:
+ browser.fill('username', username)
+ browser.fill('password', password)
+ login_submit.click()
+ else:
+ browser.visit(default_url + '/plinth/firstboot/welcome')
+ browser.find_by_value('Start Setup').click()
+ create_admin_account(browser, 'tester', 'testingtesting')
+ login(browser, url, username, password)
+
+
+def is_login_prompt(browser):
+ return all(
+ [browser.find_by_id('id_username'),
+ browser.find_by_id('id_password')])
+
+
+def nav_to_module(browser, module):
+ browser.find_link_by_href('/plinth/').first.click()
+ if module in sys_modules:
+ browser.find_link_by_href('/plinth/sys/').first.click()
+ browser.find_link_by_href('/plinth/sys/' + module + '/').first.click()
+ else:
+ browser.find_link_by_href('/plinth/apps/').first.click()
+ browser.find_link_by_href('/plinth/apps/' + module + '/').first.click()
+
+
+def submit(browser):
+ browser.find_by_value('Submit').click()
+
+
+def create_user(browser, name, password):
+ nav_to_module(browser, 'users')
+ browser.find_link_by_href('/plinth/sys/users/create/').first.click()
+ browser.find_by_id('id_username').fill(name)
+ browser.find_by_id('id_password1').fill(password)
+ browser.find_by_id('id_password2').fill(password)
+ browser.find_by_value('Create User').click()
+
+
+def rename_user(browser, old_name, new_name):
+ nav_to_module(browser, 'users')
+ browser.find_link_by_href('/plinth/sys/users/' + old_name +
+ '/edit/').first.click()
+ browser.find_by_id('id_username').fill(new_name)
+ browser.find_by_value('Save Changes').click()
+
+
+def delete_user(browser, name):
+ nav_to_module(browser, 'users')
+ browser.find_link_by_href('/plinth/sys/users/' + name +
+ '/delete/').first.click()
+ browser.find_by_value('Delete ' + name).click()
+
+
+def is_user(browser, name):
+ nav_to_module(browser, 'users')
+ return browser.is_text_present(name)
+
+
+def create_admin_account(browser, username, password):
+ browser.find_by_id('id_username').fill(username)
+ browser.find_by_id('id_password1').fill(password)
+ browser.find_by_id('id_password2').fill(password)
+ browser.find_by_value('Create Account').click()
diff --git a/functional_tests/support/service.py b/functional_tests/support/service.py
new file mode 100644
index 000000000..1dee6b8d3
--- /dev/null
+++ b/functional_tests/support/service.py
@@ -0,0 +1,55 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from time import sleep
+
+from support import interface
+
+# unlisted services just use the service_name as module name
+service_module = {
+ 'ntp': 'datetime',
+}
+
+
+def get_service_module(service_name):
+ module = service_name
+ if service_name in service_module:
+ module = service_module[service_name]
+ return module
+
+
+def is_running(browser, service_name):
+ interface.nav_to_module(browser, get_service_module(service_name))
+ return browser.is_text_present('is running')
+
+
+def is_not_running(browser, service_name):
+ interface.nav_to_module(browser, get_service_module(service_name))
+ return browser.is_text_present('is not running')
+
+
+def eventually(function, args, timeout=30):
+ """Execute a function returning a boolean expression till it returns
+ True or a timeout is reached"""
+ counter = 1
+ while counter < timeout:
+ if function(*args):
+ return True
+ else:
+ counter += 1
+ sleep(1)
+ return False
diff --git a/functional_tests/support/site.py b/functional_tests/support/site.py
new file mode 100644
index 000000000..b9b6a8367
--- /dev/null
+++ b/functional_tests/support/site.py
@@ -0,0 +1,59 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from time import sleep
+
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.keys import Keys
+
+from support import config
+from support.service import eventually
+
+# unlisted sites just use '/' + site_name as url
+site_url = {
+ 'wiki': '/ikiwiki',
+ 'jsxc': '/plinth/apps/jsxc/jsxc/',
+ 'cockpit': '/_cockpit/'
+}
+
+
+def get_site_url(site_name):
+ url = '/' + site_name
+ if site_name in site_url:
+ url = site_url[site_name]
+ return url
+
+
+def is_available(browser, site_name):
+ browser.visit(config['DEFAULT']['url'] + get_site_url(site_name))
+ sleep(3)
+ browser.reload()
+ return browser.title != '404 Not Found'
+
+
+def access_url(browser, site_name):
+ browser.visit(config['DEFAULT']['url'] + get_site_url(site_name))
+
+
+def verify_coquelicot_upload_password(browser, password):
+ browser.visit(config['DEFAULT']['url'] + '/coquelicot')
+ browser.find_by_id('upload_password').fill(password)
+ actions = ActionChains(browser.driver)
+ actions.send_keys(Keys.RETURN)
+ actions.perform()
+ assert eventually(browser.is_element_present_by_css,
+ args=['div[style*="display: none;"]'])
diff --git a/functional_tests/support/system.py b/functional_tests/support/system.py
new file mode 100644
index 000000000..501d7b2a9
--- /dev/null
+++ b/functional_tests/support/system.py
@@ -0,0 +1,88 @@
+#
+# This file is part of Plinth-tester.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from support import config
+
+from .interface import nav_to_module, submit
+
+config_page_title_language_map = {
+ 'da': 'Generel Konfiguration',
+ 'de': 'Allgemeine Konfiguration',
+ 'es': 'Configuración general',
+ 'fr': 'Configuration générale',
+ 'nb': 'Generelt oppsett',
+ 'nl': 'Algemene Instellingen',
+ 'pl': 'Ustawienia główne',
+ 'pt': 'Configuração Geral',
+ 'ru': 'Общие настройки',
+ 'sv': 'Allmän Konfiguration',
+ 'te': 'సాధారణ ఆకృతీకరణ',
+ 'tr': 'Genel Yapılandırma',
+ 'zh-hans': '常规配置',
+}
+
+
+def get_hostname(browser):
+ nav_to_module(browser, 'config')
+ return browser.find_by_id('id_configuration-hostname').value
+
+
+def set_hostname(browser, hostname):
+ nav_to_module(browser, 'config')
+ browser.find_by_id('id_configuration-hostname').fill(hostname)
+ submit(browser)
+
+
+def get_domain_name(browser):
+ nav_to_module(browser, 'config')
+ return browser.find_by_id('id_configuration-domainname').value
+
+
+def set_domain_name(browser, domain_name):
+ nav_to_module(browser, 'config')
+ browser.find_by_id('id_configuration-domainname').fill(domain_name)
+ submit(browser)
+
+
+def set_language(browser, language_code):
+ nav_to_module(browser, 'config')
+ browser.find_by_xpath(
+ '//select[@id="id_configuration-language"]//option[@value="' \
+ + language_code + '"]'
+ ).first.click()
+ submit(browser)
+
+
+def check_language(browser, language_code):
+ nav_to_module(browser, 'config')
+ return browser.title == config_page_title_language_map[language_code]
+
+
+def delete_all_snapshots(browser):
+ browser.visit(config['DEFAULT']['url'] + '/plinth/sys/snapshot/all/delete')
+ browser.find_by_value('Delete Snapshots').click()
+
+
+def create_snapshot(browser):
+ nav_to_module(browser, 'snapshot')
+ browser.find_by_value('Create Snapshot').click()
+
+
+def get_snapshot_count(browser):
+ nav_to_module(browser, 'snapshot')
+ # Subtract 1 for table header
+ return len(browser.find_by_xpath('//tr')) - 1
diff --git a/functional_tests/test_plinth.py b/functional_tests/test_plinth.py
new file mode 100644
index 000000000..3012b7d14
--- /dev/null
+++ b/functional_tests/test_plinth.py
@@ -0,0 +1,26 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from pytest_bdd import scenarios
+
+from step_definitions.application import *
+from step_definitions.interface import *
+from step_definitions.service import *
+from step_definitions.site import *
+from step_definitions.system import *
+
+scenarios('features')
diff --git a/functional_tests/todo.org b/functional_tests/todo.org
new file mode 100644
index 000000000..f69c1274b
--- /dev/null
+++ b/functional_tests/todo.org
@@ -0,0 +1,60 @@
+* Feature: Users and Groups
+** TODO Scenario: Add user to wiki group
+** TODO Scenario: Remove user from wiki group
+** TODO Scenario: Set user SSH key
+** TODO Scenario: Clear user SSH key
+** TODO Scenario: Make user inactive
+** TODO Scenario: Make user active
+** TODO Scenario: Change user password
+
+* Feature: Software Upgrades
+** TODO Scenario: Disable automatic upgrades
+** TODO Scenario: Enable automatic upgrades
+
+* Feature: Date & Time
+** TODO Scenario: Change timezone
+
+* Feature: Help
+** TODO Scenario: Visit the wiki
+** TODO Scenario: Visit the mailing list
+** TODO Scenario: Visit the IRC channel
+** TODO Scenario: View the manual
+** TODO Scenario: View the about page
+
+* Feature: Monkeysphere
+** TODO Import key
+
+* Feature: Tor
+** TODO Scenario: Disable Tor Hidden Service
+** TODO Scenario: Enable Tor Hidden Service
+** TODO Scenario: Disable software package download over Tor
+** TODO Scenario: Enable software package download over Tor
+
+* Feature: Bookmarks
+** TODO Enable/Disable
+
+* Feature: Chat Server
+** TODO Check service
+** TODO Check domain name display
+
+* Feature: Dynamic DNS Client
+** TODO Scenario: Configure GnuDIP service
+** TODO Scenario: Configure noip.com service
+** TODO Scenario: Configure selfhost.bz service
+** TODO Scenario: Configure freedns.afraid.org service
+** TODO Scenario: Configure other update URL service
+
+* Feature: News Feed Reader
+** TODO Enable/Disable
+
+* Feature: Public Visibility
+** TODO Scenario: Enable PageKite
+** TODO Scenario: Disable PageKite
+** TODO Scenario: Enable standard services
+** TODO Scenario: Disable standard services
+** TODO Scenario: Add custom service
+** TODO Scenario: Delete custom service
+
+* Feature: Wiki and Blog
+** TODO Create wiki
+** TODO Delete wiki