From fb997c4f900e5e096c577e5d335f44561a12b1c4 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Fri, 1 Oct 2021 01:18:41 +0100 Subject: [PATCH 01/12] Ensure WP-CLI logging goes to stderr --- scripts/wp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/wp.sh b/scripts/wp.sh index 0021371..05a3bf1 100755 --- a/scripts/wp.sh +++ b/scripts/wp.sh @@ -9,4 +9,4 @@ # installing it as root is idiocy. WP needs to be installed owned by a user # seperate from the server's user. 'root' is available for such, besides which # root in a container is not really root. -exec php -d memory_limit=512M /usr/local/lib/wp-cli.phar --allow-root "$@" +exec php -d memory_limit=512M -d display_errors=stderr /usr/local/lib/wp-cli.phar --allow-root "$@" -- GitLab From b23cd5bbdee69775c82d6688e135c675203591fd Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Fri, 15 Oct 2021 02:58:16 +0100 Subject: [PATCH 02/12] Update pre-commit config --- .pre-commit-config.yaml | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52f815c..442e154 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,47 +1,40 @@ +default_stages: [commit] repos: - repo: meta hooks: - id: check-hooks-apply + - id: check-useless-excludes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: check-added-large-files - stages: [commit] - id: check-case-conflict - stages: [commit] - id: check-merge-conflict - stages: [commit] - id: check-yaml args: [--allow-multiple-documents] - stages: [commit] - id: destroyed-symlinks - stages: [commit] - id: end-of-file-fixer - stages: [commit] + stages: [commit, manual] - id: fix-byte-order-marker - stages: [commit] - id: mixed-line-ending args: [--fix=lf] - stages: [commit] + stages: [commit, manual] - id: trailing-whitespace exclude_types: [markdown, plain-text] - stages: [commit] + stages: [commit, manual] - repo: https://github.com/jorisroovers/gitlint rev: v0.15.0 hooks: - id: gitlint -- repo: https://github.com/jumanjihouse/pre-commit-hooks - rev: 2.1.5 - hooks: - - id: protect-first-parent - - repo: https://code.kodo.org.uk/dom/pre-commit-hooks - rev: v0.5.1 + rev: v0.6 hooks: + - id: check-executable-modes - id: check-for-squash - id: copyright-notice exclude: ^data/ + - id: protect-first-parent -- GitLab From 3128b302d233ed99a3594f5b372877040f442661 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Fri, 15 Oct 2021 03:23:48 +0100 Subject: [PATCH 03/12] Add initial behaviour tests --- .gitignore | 2 + .lint.cfg | 104 ++++++ .pre-commit-config.yaml | 60 ++++ behave.ini | 2 + tests/README.md | 74 ++++ tests/environment.py | 185 ++++++++++ tests/home-page.feature | 22 ++ tests/mysql-init.sql | 3 + tests/regression-14.feature | 27 ++ tests/requirements.txt | 6 + tests/steps/pages.py | 161 +++++++++ tests/steps/request_steps.py | 58 +++ tests/stubs/behave/__init__.pyi | 30 ++ tests/stubs/behave/capture.pyi | 43 +++ tests/stubs/behave/fixture.pyi | 118 +++++++ tests/stubs/behave/formatter/__init__.pyi | 0 tests/stubs/behave/formatter/base.pyi | 58 +++ tests/stubs/behave/log_capture.pyi | 55 +++ tests/stubs/behave/matchers.pyi | 86 +++++ tests/stubs/behave/model.pyi | 250 +++++++++++++ tests/stubs/behave/model_core.pyi | 123 +++++++ tests/stubs/behave/runner.pyi | 148 ++++++++ tests/stubs/behave/step_registry.pyi | 27 ++ tests/stubs/behave/tag_expression.pyi | 22 ++ tests/utils/__init__.py | 29 ++ tests/utils/behave.py | 106 ++++++ tests/utils/http.py | 70 ++++ tests/utils/json.py | 68 ++++ tests/utils/secret.py | 23 ++ tests/utils/url.py | 28 ++ tests/wp/__init__.py | 177 ++++++++++ tests/wp/docker.py | 411 ++++++++++++++++++++++ tests/wp/proc.py | 158 +++++++++ 33 files changed, 2734 insertions(+) create mode 100644 .gitignore create mode 100644 .lint.cfg create mode 100644 behave.ini create mode 100644 tests/README.md create mode 100644 tests/environment.py create mode 100644 tests/home-page.feature create mode 100644 tests/mysql-init.sql create mode 100644 tests/regression-14.feature create mode 100644 tests/requirements.txt create mode 100644 tests/steps/pages.py create mode 100644 tests/steps/request_steps.py create mode 100644 tests/stubs/behave/__init__.pyi create mode 100644 tests/stubs/behave/capture.pyi create mode 100644 tests/stubs/behave/fixture.pyi create mode 100644 tests/stubs/behave/formatter/__init__.pyi create mode 100644 tests/stubs/behave/formatter/base.pyi create mode 100644 tests/stubs/behave/log_capture.pyi create mode 100644 tests/stubs/behave/matchers.pyi create mode 100644 tests/stubs/behave/model.pyi create mode 100644 tests/stubs/behave/model_core.pyi create mode 100644 tests/stubs/behave/runner.pyi create mode 100644 tests/stubs/behave/step_registry.pyi create mode 100644 tests/stubs/behave/tag_expression.pyi create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/behave.py create mode 100644 tests/utils/http.py create mode 100644 tests/utils/json.py create mode 100644 tests/utils/secret.py create mode 100644 tests/utils/url.py create mode 100644 tests/wp/__init__.py create mode 100644 tests/wp/docker.py create mode 100644 tests/wp/proc.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5847d92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Python (for tests) +*.py[co] diff --git a/.lint.cfg b/.lint.cfg new file mode 100644 index 0000000..f560ed0 --- /dev/null +++ b/.lint.cfg @@ -0,0 +1,104 @@ +[isort] +force_single_line = true + +[mypy] +strict = true +warn_unused_configs = true +warn_unreachable = true +;implicit_reexport = true +mypy_path = tests/stubs +plugins = + trio_typing.plugin + +[flake8] +max-line-length = 92 +max-doc-length = 92 +use-flake8-tabs = true +blank-lines-indent = never +indent-tabs-def = 1 +format = pylint +select = C,D,E,ET,F,SFS,T,W,WT + +per-file-ignores = + **/__init__.py: D104 + **/__main__.py: D100, E702 + +ignore = + ;[ '%s' imported but unused ] + ; Handled by pylint, which does it better + F401 + + ;[ Missing docstring in public method ] + ; Handled by pylint, which does it better + D102 + + ;[ Missing docstring in magic method ] + ; Magic/dunder methods are well-known + D105 + + ;[ Misisng docstring in __init__ ] + ; Document basic construction in the class docstring + D107 + + ;[ One-line docstring should fit on one line with quotes ] + ; Prefer top-and-bottom style always + D200 + + ;[ Docstring should be indented with spaces, not tabs ] + ; Tabs, absolutely always + D206 + + ;[ Use r""" if any backslashes in a docstring ] + ; If I want to put escape chars in a docstring, I will + D301 + + ;[ Use u""" for Unicode docstrings ] + ; This must be for Python 2? + D302 + + ;[ First line should end with a period ] + ; First line should *NEVER* end with a period + D400 + + ;[ First line should be in the imperative mood ] + ; I like this for functions and methods, not for properties. This stands until + ; pydocstyle splits a new code for properties or flake8 adds some way of + ; filtering codes with line regexes like golangci-lint. + D401 + + ;[ No blank lines allowed between a section header and its content ] + D412 + + ;[ missing whitespace around bitwise or shift operator ] + E227 + + ;[ Line too long ] + ; Prefer B950 implementation + E501 + + ;[ multiple statements on one line (def) ] + ; Dosen't work well with short @overload definitions + E704 + + ;[ unexpected number of tabs and spaces at start of statement ] + ET128 + + ;[ Line break before binary operator ] + ; Not considered current + W503 + + ;[ Format-method string formatting ] + ; Allow this style + SFS201 + + ;[ f-string string formatting ] + ; Allow this style + SFS301 + +include = + ;[ First word of the docstring should not be This ] + D404 + + ; flake8-bugbear plugin + ; B950 is a replacement for E501 + B0 B903 B950 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 442e154..e7292c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,13 +11,17 @@ repos: hooks: - id: check-added-large-files - id: check-case-conflict + - id: check-docstring-first - id: check-merge-conflict - id: check-yaml args: [--allow-multiple-documents] + - id: debug-statements - id: destroyed-symlinks - id: end-of-file-fixer stages: [commit, manual] - id: fix-byte-order-marker + - id: fix-encoding-pragma + args: [--remove] - id: mixed-line-ending args: [--fix=lf] stages: [commit, manual] @@ -38,3 +42,59 @@ repos: - id: copyright-notice exclude: ^data/ - id: protect-first-parent + +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.8.0 + hooks: + - id: python-no-eval + - id: python-no-log-warn + - id: python-use-type-annotations + +- repo: https://github.com/hakancelik96/unimport + rev: 0.9.2 + hooks: + - id: unimport + args: ["--remove", "--include=\\.pyi?$"] + types: [] + types_or: [python, pyi] + stages: [commit, manual] + +- repo: https://github.com/pycqa/isort + rev: 5.9.3 + hooks: + - id: isort + args: ["--settings=.lint.cfg"] + stages: [commit, manual] + +- repo: https://github.com/asottile/add-trailing-comma + rev: v2.1.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] + types: [] + types_or: [python, pyi] + stages: [commit, manual] + +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + args: ["--config=.lint.cfg"] + additional_dependencies: + - flake8-bugbear + - flake8-docstrings + - flake8-print + - flake8-requirements + - flake8-return + - flake8-sfs + - flake8-tabs + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.910 + hooks: + - id: mypy + args: ["--config-file=.lint.cfg"] + additional_dependencies: + - trio-typing + - types-requests + - git+https://code.kodo.org.uk/dom/type-stubs.git#type-stubs[jsonpath,parse] diff --git a/behave.ini b/behave.ini new file mode 100644 index 0000000..a7da884 --- /dev/null +++ b/behave.ini @@ -0,0 +1,2 @@ +[behave] +paths = tests diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..989f7b0 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,74 @@ +Behaviour Testing +================= + +These behaviour tests use "Behave", a Python framework. + + +Requirements +------------ + +### Docker (>=18.09) + +Docker is required for running the project; a minimum version of 18.09 is required to build +the images. + +### Python (>=3.9) + +The tests are coordinated and run by "behave", a Python testing framework. In order to +check the correctness of the test code it has been written with the latest typing features +of Python. + + +Installing +---------- + +There are a small number of Python package dependencies listed in [requirements.txt]() which +must be installed; it is recommended that they are installed in a [virtual +environment][venv]: + +```bash +env=venv # You may choose any directory name here +python -m venv $env +$env/bin/python -m pip install -r tests/requirements.txt +``` + +### OPTIONAL: Make `behave` runnable without a full path + +The virtual environment's *bin/* directory (*Scripts/* for Windows builds of Python) can be +added to the executable search variable "PATH". This will make `behave` and other installed +tools runnable without having to supply a full path to the executable. The 'venv' tool +supplies handy scripts for this purpose. + +All the following example in this document assume this has been done; if not simply replace +`behave` with `$env/bin/behave` where `$env` expands to the virtual environment created +above. + +For Bash and Zsh use the following, for other shells see the [venv][] documentation: + +```bash +source $env/bin/activate +``` + +[venv]: + https://docs.python.org/3/library/venv.html + "Documentation for 'venv'" + + +Usage +----- + +From the top directory of the project or the *tests* subdirectory, Behave can be called with +no arguments to run all scenarios: + +```bash +behave +``` + +Behave can be run with path arguments in which case it can be run from any directory. +Feature files (matching `*.feature`) may be specified to run their scenarios, or if the path +is a directory the tree will be searched for feature files. + +```bash +behave tests # Run scenarios for all features +behave tests/regression-*.feature # Run regression scenarios +``` diff --git a/tests/environment.py b/tests/environment.py new file mode 100644 index 0000000..b871126 --- /dev/null +++ b/tests/environment.py @@ -0,0 +1,185 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Setup module for Behave tests + +This module prepares test fixtures and global context items. + +https://behave.readthedocs.io/en/stable/tutorial.html#environmental-controls +""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterator +from typing import NamedTuple + +from behave import fixture +from behave import use_fixture +from behave.model import Feature +from behave.model import Scenario +from behave.runner import Context +from requests.sessions import Session +from utils import URL +from utils import make_secret +from utils import redirect +from wp import Mysql +from wp import Wordpress +from wp.docker import Container +from wp.docker import Image +from wp.docker import IPv4Address +from wp.docker import Network + +if TYPE_CHECKING: + from behave.runner import FeatureContext + from behave.runner import ScenarioContext + +SITE_URL = URL("http://test.example.com") +BUILD_CONTEXT = Path(__file__).parent.parent + + +def before_all(context: Context) -> None: + """ + Setup fixtures for all tests + """ + context.site = use_fixture(setup_test_cluster, context, SITE_URL) + + +def before_feature(context: FeatureContext, feature: Feature) -> None: + """ + Setup/revert fixtures before each feature + """ + use_fixture(db_snapshot_rollback, context) + + +def before_scenario(context: ScenarioContext, scenario: Scenario) -> None: + """ + Setup tools for each scenario + """ + context.session = use_fixture(requests_session, context) + + +class Site(NamedTuple): + """ + A named-tuple of information about the containers for a site fixture + """ + + url: str + address: IPv4Address + frontend: Container + backend: Wordpress + database: Mysql + + +# Todo(dom.sekotill): When PEP-612 is properly implemented in mypy the [*a, **k] and default +# values nonsense can be removed from fixtures + +@fixture +def setup_test_cluster(context: Context, /, site_url: str|None = None, *a: Any, **k: Any) -> Iterator[Site]: + """ + Prepare and return the details of a site fixture + """ + assert site_url is not None, \ + "site_url is required, but default supplied until PEP-612 supported" + with test_cluster(site_url) as site: + yield site + + +@fixture +def requests_session(context: ScenarioContext, /, *a: Any, **k: Any) -> Iterator[Session]: + """ + Create and configure a `requests` session for accessing site fixtures + """ + site = context.site + with Session() as session: + redirect(session, site.url, site.address) + yield session + + +@fixture +def db_snapshot_rollback(context: FeatureContext, /, *a: Any, **k: Any) -> Iterator[None]: + """ + Manage the state of a site's database as a revertible fixture + """ + db = context.site.database + snapshot = db.mysqldump("--all-databases", deserialiser=bytes) + yield + db.mysql(input=snapshot) + + +@contextmanager +def test_cluster(site_url: str) -> Iterator[Site]: + """ + Configure and start all the necessary containers for use as test fixtures + """ + test_dir = Path(__file__).parent + + db_secret = make_secret(20) + db_init = test_dir / "mysql-init.sql" + + with Network() as network: + database = Mysql( + Image.pull("mysql/mysql-server"), + network=network, + volumes={ + Path("/var/lib/mysql"), + (db_init, Path("/docker-entrypoint-initdb.d") / db_init.name), + }, + env=dict( + MYSQL_DATABASE="test-db", + MYSQL_USER="test-db-user", + MYSQL_PASSWORD=db_secret, + ), + ) + frontend = Container( + Image.build(BUILD_CONTEXT, target='nginx'), + network=network, + volumes=[ + ("static", Path("/app/static")), + ("media", Path("/app/media")), + ], + ) + backend = Wordpress( + Image.build(BUILD_CONTEXT), + network=network, + volumes=frontend.volumes, + env=dict( + SITE_URL=site_url, + SITE_ADMIN_EMAIL="test@kodo.org.uk", + DB_NAME="test-db", + DB_USER="test-db-user", + DB_PASS=db_secret, + DB_HOST="database:3306", + ), + ) + + backend.connect(network, "upstream") + database.connect(network, "database") + + with database.started(), backend.started(), frontend.started(): + addr = frontend.inspect( + f"$.NetworkSettings.Networks.{network}.IPAddress", + str, IPv4Address, + ) + yield Site(site_url, addr, frontend, backend, database) + + +if __name__ == "__main__": + from os import environ + from subprocess import run + + with test_cluster(SITE_URL) as site: + run([environ.get("SHELL", "/bin/sh")]) + +elif not sys.stderr.isatty(): + import logging + + logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) diff --git a/tests/home-page.feature b/tests/home-page.feature new file mode 100644 index 0000000..4b3059d --- /dev/null +++ b/tests/home-page.feature @@ -0,0 +1,22 @@ +Feature: Homepage + The homepage should display either a static page or a feed + + Scenario: static homepage + Given a page exists containing + """ + This is some page content + """ + And the page is configured as the homepage + When the homepage is requested + Then OK is returned + And we will see the page text + + Scenario: feed homepage + Given a post exists containing + """ + This is some post content + """ + And the homepage is the default + When the homepage is requested + Then OK is returned + And we will see the post text diff --git a/tests/mysql-init.sql b/tests/mysql-init.sql new file mode 100644 index 0000000..c7b9c58 --- /dev/null +++ b/tests/mysql-init.sql @@ -0,0 +1,3 @@ +INSTALL PLUGIN auth_socket SONAME 'auth_socket.so'; + +ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket; diff --git a/tests/regression-14.feature b/tests/regression-14.feature new file mode 100644 index 0000000..f5a383e --- /dev/null +++ b/tests/regression-14.feature @@ -0,0 +1,27 @@ +Feature: Return 404 for unknown path + Regression check for "#14": don't redirect to the homepage when a 404 would + be expected. + + Scenario Outline: Not found + Given does not exist + When is requested + Then "Not Found" is returned + + Examples: + | path | + | /this/is/missing | + | /this/is/missing/ | + + Scenario Outline: Bad pagination paths + Given a page exists containing + """ + Some content + """ + When the page suffixed with is requested + Then is returned + + Examples: + | suffix | result | + | / | OK | + | /0 | OK | + | /foo | Not Found | diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..77b0073 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,6 @@ +Python ~=3.9; python_version < '3.9' + +behave +jsonpath-python ~=1.0 +requests ~=2.26 +trio ~=0.19.0 diff --git a/tests/steps/pages.py b/tests/steps/pages.py new file mode 100644 index 0000000..9feeaee --- /dev/null +++ b/tests/steps/pages.py @@ -0,0 +1,161 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Step implementations involving creating and requesting WP posts (and pages) +""" + +from __future__ import annotations + +from codecs import decode as utf8_decode +from typing import Any +from typing import Iterator + +from behave import fixture +from behave import given +from behave import then +from behave import use_fixture +from behave import when +from behave.runner import Context +from request_steps import get_request +from utils import URL +from utils import JSONArray +from utils import JSONObject +from utils import PatternEnum + +DEFAULT_CONTENT = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut +labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco +laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in +voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat +cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +""" + + +class PostType(PatternEnum): + """ + Enumeration for matching WP post types in step texts + """ + + post = "post" + page = "page" + + +@given("{path} does not exist") +def assert_not_exist(context: Context, path: str) -> None: + """ + Assert that the path does not route to any resource + """ + cmd = [ + "post", "list", "--field=url", "--format=json", + "--post_type=post,page", "--post_status=publish", + ] + urls = {*context.site.backend.cli(*cmd, deserialiser=JSONArray.from_string)} + assert context.site.url / path not in urls + + +@given("a {post_type:PostType} exists containing") +def create_post(context: Context, post_type: PostType, text: str|None = None) -> None: + """ + Create a WP post of the given type and store it in the context with the type as the name + """ + post = use_fixture(wp_post, context, post_type, text or context.text) + setattr(context, post_type.value, post) + + +@given("the page is configured as the homepage") +def set_homepage(context: Context) -> None: + """ + Set the WP page from the context as the configured front page + """ + wp = context.site.backend + pageid = context.page.path("$.ID", int) + wp.cli("option", "update", "page_on_front", str(pageid)) + wp.cli("option", "update", "show_on_front", "page") + + page = use_fixture(wp_post, context, PostType.page) + wp.cli("option", "update", "page_for_posts", page.path("$.ID", int, str)) + + +@given("the homepage is the default") +def reset_homepage(context: Context) -> None: + """ + Ensure the front page is reverted to it's default + """ + context.site.backend.cli("option", "update", "show_on_front", "post") + + +@when("the {post_type:PostType} is requested") +def request_page(context: Context, post_type: PostType) -> None: + """ + Request the specified WP post of the given type in the context + """ + post = getattr(context, post_type.value) + get_request(context, post.path("$.url", URL)) + + +@when("the {post_type:PostType} suffixed with {suffix} is requested") +def request_page_with_suffix(context: Context, post_type: PostType, suffix: str) -> None: + """ + Like `request_page`, with additional URL components appended to the post's URL + """ + post = getattr(context, post_type.value) + get_request(context, post.path("$.url", URL) + suffix) + + +@then("we will see the {post_type:PostType} text") +def assert_contains( + context: Context, + post_type: PostType = PostType.post, + text: str|None = None, +) -> None: + """ + Assert that the text is in the response from a previous step + + The text can be supplied directly or taken from a WP post of the type specified, taken + from the context. + """ + if not text: + post = getattr(context, post_type.value) + text = post.path("$.post_content", str) + assert text in context.response.text + + +@fixture +def wp_post( + context: Context, /, + post_type: PostType|None = None, + content: str = DEFAULT_CONTENT, + *a: Any, + **k: Any, +) -> Iterator[JSONObject]: + """ + Create a WP post fixture of the given type with the given content + """ + assert post_type is not None, \ + "post_type MUST be supplied to use_fixture when calling with wp_post" + + wp = context.site.backend + postid = wp.cli( + "post", "create", + f"--post_type={post_type.value}", "--post_status=publish", + f"--post_name=test-{post_type.value}", + f"--post_title=Test {post_type.name.capitalize()}", + "-", "--porcelain", + input=content, deserialiser=utf8_decode, + ).strip() + post = wp.cli("post", "get", postid, "--format=json", deserialiser=JSONObject.from_string) + post.update( + url=URL( + wp.cli( + "post", "list", "--field=url", + f"--post__in={postid}", f"--post_type={post_type.value}", + deserialiser=utf8_decode, + ).strip(), + ), + ) + yield post + wp.cli("post", "delete", postid) diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py new file mode 100644 index 0000000..ca489b9 --- /dev/null +++ b/tests/steps/request_steps.py @@ -0,0 +1,58 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Step implementations dealing with HTTP requests +""" + +from __future__ import annotations + +from behave import then +from behave import when +from behave.runner import Context +from utils import URL +from utils import PatternEnum + + +class ResponseCode(int, PatternEnum): + """ + HTTP response codes + """ + + ok = 200 + not_found = 404 + + members = { + "200": 200, "OK": 200, + "404": 404, "Not Found": 404, + } + + +@when("{url:URL} is requested") +def get_request(context: Context, url: URL) -> None: + """ + Assign the response from making a GET request to "url" to the context + """ + context.response = context.session.get(context.site.url / url) + + +@when("the homepage is requested") +def get_homepage(context: Context) -> None: + """ + Assign the response from making a GET request to the base URL to the context + """ + get_request(context, '/') + + +@then('"{response:ResponseCode}" is returned') +@then('{response:ResponseCode} is returned') +def assert_response(context: Context, response: ResponseCode) -> None: + """ + Assert that the expected response was received during a previous step + + "response" can be a numeric or phrasal response in ResponseCode + """ + assert context.response.status_code == response diff --git a/tests/stubs/behave/__init__.pyi b/tests/stubs/behave/__init__.pyi new file mode 100644 index 0000000..289273d --- /dev/null +++ b/tests/stubs/behave/__init__.pyi @@ -0,0 +1,30 @@ +from .fixture import fixture +from .fixture import use_fixture +from .matchers import register_type +from .matchers import use_step_matcher +from .step_registry import Given +from .step_registry import Step +from .step_registry import Then +from .step_registry import When +from .step_registry import given +from .step_registry import step +from .step_registry import then +from .step_registry import when + +# .matchers.step_matcher is deprecated, not including + +__version__: str +__all__ = [ + "Given", + "Step", + "Then", + "When", + "fixture", + "given", + "register_type", + "step", + "then", + "use_fixture", + "use_step_matcher", + "when", +] diff --git a/tests/stubs/behave/capture.pyi b/tests/stubs/behave/capture.pyi new file mode 100644 index 0000000..8bf42bf --- /dev/null +++ b/tests/stubs/behave/capture.pyi @@ -0,0 +1,43 @@ +from io import StringIO +from typing import IO + +from .log_capture import LoggingCapture +from .model import Configuration +from .runner import Context + +class Captured: + + stdout: str + stderr: str + log_output: str + + @property + def output(self) -> str: ... + + def __init__(self, stdout: str = ..., stderr: str = ..., log_output: str = ...): ... + def __add__(self, other: Captured) -> Captured: ... + + def reset(self) -> None: ... + def add(self, captured: Captured) -> Captured: ... + def make_report(self) -> str: ... + + +class CaptureController: + + config: Configuration + stdout_capture: StringIO + stderr_capture: StringIO + log_capture: LoggingCapture + old_stdout: IO[str] + old_stderr: IO[str] + + @property + def capture(self) -> Captured: ... + + def __init__(self, config: Configuration): ... + + def setup_capture(self, context: Context) -> None: ... + def start_capture(self) -> None: ... + def stop_capture(self) -> None: ... + def teardown_capture(self) -> None: ... + def make_capture_report(self) -> str: ... diff --git a/tests/stubs/behave/fixture.pyi b/tests/stubs/behave/fixture.pyi new file mode 100644 index 0000000..4af0fab --- /dev/null +++ b/tests/stubs/behave/fixture.pyi @@ -0,0 +1,118 @@ +import sys +from typing import Any +from typing import Iterator +from typing import Protocol +from typing import TypeVar +from typing import overload + +from .runner import Context + +C = TypeVar("C", bound=Context) +C_con = TypeVar("C_con", bound=Context, contravariant=True) + +R = TypeVar("R") +R_co = TypeVar("R_co", covariant=True) + + +# There's a lot of @overload-ed functions here as fixtures come in two varieties: +# 1) A @contextlib.contextmanager-like generator that yields an arbitrary object once. +# 2) A simple function that returns an arbitrary object +# +# "use_fixture" allows both types of fixture callables to be used in the same way + +if sys.version_info >= (3, 10) and False: + # This depends on complete support of ParamSpec in mypy so is disabled for now. + + from typing import ParamSpec + + P = ParamSpec("P") + + + class FixtureCoroutine(Protocol[C_con, P, R_co]): + def __call__(self, _: C_con, /, *__a: P.args, **__k: P.kwargs) -> Iterator[R_co]: ... + + class FixtureFunction(Protocol[C_con, P, R_co]): + def __call__(self, _: C_con, /, *__a: P.args, **__k: P.kwargs) -> R_co: ... + + + @overload + def use_fixture( + fixture_func: FixtureCoroutine[C_con, P, R], + context: C_con, + *a: P.args, + **k: P.kwargs, + ) -> R: ... + + @overload + def use_fixture( + fixture_func: FixtureFunction[C_con, P, R], + context: C_con, + *a: P.args, + **k: P.kwargs, + ) -> R: ... + +else: + # Without ParamSpec no checking is done to ensure the arguments passed to use_fixture + # match the fixture's arguments; fixtures must be able to handle arguments not being + # supplied (except the context); and fixtures must accept ANY arbitrary keyword + # arguments. + + P = TypeVar("P", bound=None) + P_co = TypeVar("P_co", covariant=True) # unused + + + class FixtureCoroutine(Protocol[C_con, P_co, R_co]): + def __call__(self, _: C_con, /, *__a: Any, **__k: Any) -> Iterator[R_co]: ... + + class FixtureFunction(Protocol[C_con, P_co, R_co]): + def __call__(self, _: C_con, /, *__a: Any, **__k: Any) -> R_co: ... + + + @overload + def use_fixture( + fixture_func: FixtureCoroutine[C_con, P_co, R_co], + context: C_con, + *a: Any, + **k: Any, + ) -> R_co: ... + + @overload + def use_fixture( + fixture_func: FixtureFunction[C_con, P_co, R_co], + context: C_con, + *a: Any, + **k: Any, + ) -> R_co: ... + + +# "fixture" is a decorator used to mark both types of fixture callables. It can also return +# a decorator, when called without the "func" argument. + +@overload +def fixture( + func: FixtureCoroutine[C, P, R], + name: str = ..., + pattern: str = ..., +) -> FixtureCoroutine[C, P, R]: ... + +@overload +def fixture( + func: FixtureFunction[C, P, R], + name: str = ..., + pattern: str = ..., +) -> FixtureFunction[C, P, R]: ... + +@overload +def fixture( + name: str = ..., + pattern: str = ..., +) -> FixtureDecorator: ... + + +class FixtureDecorator(Protocol): + + @overload + def __call__(self, _: FixtureCoroutine[C, P, R], /) -> FixtureCoroutine[C, P, R]: ... + + @overload + def __call__(self, _: FixtureFunction[C, P, R], /) -> FixtureFunction[C, P, R]: ... diff --git a/tests/stubs/behave/formatter/__init__.pyi b/tests/stubs/behave/formatter/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/tests/stubs/behave/formatter/base.pyi b/tests/stubs/behave/formatter/base.pyi new file mode 100644 index 0000000..df454b5 --- /dev/null +++ b/tests/stubs/behave/formatter/base.pyi @@ -0,0 +1,58 @@ +from typing import IO +from typing import ClassVar +from typing import Protocol + +from ..matchers import Match +from ..model import Background +from ..model import Configuration +from ..model import Feature +from ..model import Scenario +from ..model import Step + +class StreamOpener: + + default_encoding: ClassVar[str] + + name: str + stream: IO[str] + encoding: str + should_close_stream = bool + + def __init__( + self, + filename: str = ..., + stream: IO[str] = ..., + encoding: str = ..., + ): ... + + @staticmethod + def ensure_dir_exists(directory: str) -> None: ... + + @classmethod + def ensure_stream_with_encoder(cls, stream: IO[str], encoding: str = ...) -> IO[str]: ... + + def open(self) -> IO[str]: ... + def close(self) -> bool: ... + + +class Formatter(Protocol): + + name: str + description: str + + def __init__(self, stream_opener: StreamOpener, config: Configuration): ... + + @property + def stdout_mode(self) -> bool: ... + + def open(self) -> IO[str]: ... + def uri(self, uri: str) -> None: ... + def feature(self, feature: Feature) -> None: ... + def background(self, background: Background) -> None: ... + def scenario(self, scenario: Scenario) -> None: ... + def step(self, step: Step) -> None: ... + def match(self, match: Match) -> None: ... + def result(self, step: Step) -> None: ... + def eof(self) -> None: ... + def close(self) -> None: ... + def close_stream(self) -> None: ... diff --git a/tests/stubs/behave/log_capture.pyi b/tests/stubs/behave/log_capture.pyi new file mode 100644 index 0000000..4692f7e --- /dev/null +++ b/tests/stubs/behave/log_capture.pyi @@ -0,0 +1,55 @@ +from logging import Handler +from logging import LogRecord +from logging.handlers import BufferingHandler +from typing import Any +from typing import Callable +from typing import Protocol +from typing import TypeVar +from typing import overload + +from .model import Configuration +from .runner import ScenarioContext + +class RecordFilter: + + include: set[str] + exclude: set[str] + + def __init__(self, names: str): ... + + def filter(self, record: LogRecord) -> bool: ... + + +class LoggingCapture(BufferingHandler): + + config: Configuration + old_handlers: list[Handler] + old_level: int|None + + def __init__(self, config: Configuration, level: int = ...): ... + + def flush(self) -> None: ... + def truncate(self) -> None: ... + def getvalue(self) -> str: ... + def find_event(self, pattern: str) -> bool: ... + def any_errors(self) -> bool: ... + def inveigle(self) -> None: ... + def abandon(self) -> None: ... + + +MemoryHandler = LoggingCapture + + +class Hook(Protocol): + def __call__(self, _: ScenarioContext, /, *a: Any, **k: Any) -> None: ... + + +H = TypeVar("H", bound=Hook) + + +@overload +def capture(level: int = ...) -> Callable[[H], H]: ... + + +@overload +def capture(func: H, level: int = ...) -> H: ... diff --git a/tests/stubs/behave/matchers.pyi b/tests/stubs/behave/matchers.pyi new file mode 100644 index 0000000..8ff68f0 --- /dev/null +++ b/tests/stubs/behave/matchers.pyi @@ -0,0 +1,86 @@ +from typing import Any +from typing import Callable +from typing import ClassVar +from typing import Optional +from typing import Sequence +from typing import TypeVar + +from parse import Parser +from parse import TypeConverter + +from .model_core import Argument +from .model_core import FileLocation +from .runner import Context +from .step_registry import StepFunction + +Arguments = Optional[list[Argument]] + +T = TypeVar("T") + + +matcher_mapping: dict[str, Matcher] +current_matcher: Matcher + + +def register_type(**kw: Callable[[str], Any]) -> None: ... +def use_step_matcher(name: str) -> None: ... +def get_matcher(func: StepFunction, pattern: str) -> Matcher: ... + + +class StepParserError(ValueError): ... + + +class Match: + + func: StepFunction + arguments: Sequence[Argument]|None + location: int + + def __init__(self, func: StepFunction, arguments: Sequence[Argument] = ...): ... + def with_arguments(self, arguments: Sequence[Argument]) -> Match: ... + def run(self, context: Context) -> None: ... + + @staticmethod + def make_location(step_function: StepFunction) -> FileLocation: ... + + +class NoMatch(Match): + + def __init__(self) -> None: ... + + +class MatchWithError(Match): + + def __init__(self, func: StepFunction, error: BaseException): ... + + +class Matcher: + + schema: ClassVar[str] + + pattern: str + func: StepFunction + + def __init__(self, func: StepFunction, pattern: str, step_type: str = ...): ... + + @property + def location(self) -> FileLocation: ... + + @property + def regex_pattern(self) -> str: ... + + def describe(self, schema: str = ...) -> str: ... + def check_match(self, step: str) -> Arguments: ... + def match(self, step: StepFunction) -> Match: ... + + +class ParseMatcher(Matcher): + + custom_types: ClassVar[dict[str, TypeConverter[Any]]] + parser_class: ClassVar[type[Parser]] + + +class CFParseMatcher(ParseMatcher): ... +class RegexMatcher(Matcher): ... +class SimplifiedRegexMatcher(RegexMatcher): ... +class CucumberRegexMatcher(RegexMatcher): ... diff --git a/tests/stubs/behave/model.pyi b/tests/stubs/behave/model.pyi new file mode 100644 index 0000000..9372d29 --- /dev/null +++ b/tests/stubs/behave/model.pyi @@ -0,0 +1,250 @@ +from typing import Any +from typing import ClassVar +from typing import Iterable +from typing import Iterator +from typing import Literal +from typing import Protocol +from typing import Sequence + +from .model_core import BasicStatement +from .model_core import Replayable +from .model_core import Status +from .model_core import TagAndStatusStatement +from .runner import ModelRunner +from .tag_expression import TagExpression + +Configuration = dict[str, Any] + + +def reset_model(model_elements: Iterable[ModelElement]) -> None: ... + + +class ModelElement(Protocol): + + def reset(self) -> None: ... + + +class Feature(TagAndStatusStatement, Replayable): + + type: ClassVar[Literal["feature"]] + + keyword: str + name: str + description: list[str] + background: Background + scenarios: list[Scenario] + tags: list[Tag] + hook_failed: bool + filename: str + line: int + language: str + + @property + def status(self) -> Status: ... + + @property + def duration(self) -> float: ... + + def __init__( + self, + filename: str, + line: int, + keyword: str, + name: str, + tags: Sequence[Tag] = ..., + description: str = ..., + scenarios: Sequence[Scenario] = ..., + background: Background = ..., + language: str = ..., + ): ... + def __iter__(self) -> Iterator[Scenario]: ... + + def reset(self) -> None: ... + def add_scenario(self, scenario: Scenario) -> None: ... + def compute_status(self) -> Status: ... + def walk_scenarios(self, with_outlines: bool = ...) -> list[Scenario]: ... + def should_run(self, config: Configuration = ...) -> bool: ... + def should_run_with_tags(self, tag_expression: TagExpression) -> bool: ... + def mark_skipped(self) -> None: ... + def skip(self, reason: str = ..., require_not_executed: bool = ...) -> None: ... + def run(self, runner: ModelRunner) -> bool: ... + + +class Background(BasicStatement, Replayable): + + type: ClassVar[Literal["background"]] + + keyword: str + name: str + steps: list[Step] + filename: str + line: int + + @property + def duration(self) -> float: ... + + def __init__( + self, + filename: str, + line: int, + keyword: str, + name: str, + steps: Sequence[Step] = ..., + ): ... + def __iter__(self) -> Iterator[Step]: ... + + +class Scenario(TagAndStatusStatement, Replayable): + + type: ClassVar[Literal["scenario"]] + continue_after_failed_step: ClassVar[bool] + + keyword: str + name: str + description: str + feature: Feature + steps: Sequence[Step] + tags: Sequence[Tag] + hook_failed: bool + filename: str + line: int + + @property + def background_steps(self) -> list[Step]: ... + + @property + def all_steps(self) -> Iterator[Step]: ... + + @property + def duration(self) -> float: ... + + @property + def effective_tags(self) -> list[Tag]: ... + + def __init__( + self, + filename: str, + line: int, + keyword: str, + name: str, + tags: Sequence[Tag] = ..., + steps: Sequence[Step] = ..., + description: str = ..., + ): ... + def __iter__(self) -> Iterator[Step]: ... + + def reset(self) -> None: ... + def compute_status(self) -> Status: ... + def should_run(self, config: Configuration = ...) -> bool: ... + def should_run_with_tags(self, tag_expression: TagExpression) -> bool: ... + def should_run_with_name_select(self, config: Configuration) -> bool: ... + def mark_skipped(self) -> None: ... + def skip(self, reason: str = ..., require_not_executed: bool = ...) -> None: ... + def run(self, runner: ModelRunner) -> bool: ... + + +class Step(BasicStatement, Replayable): + + type: ClassVar[Literal["step"]] + + keyword: str + name: str + step_type: str + text: Text|None + table: Table|None + status: Status + hook_failed: bool + duration: float + error_message: str|None + filename: str + line: int + + def __init__( + self, + filename: str, + line: int, + keyword: str, + step_type: str, + name: str, + text: Text = ..., + table: Table = ..., + ): ... + def __eq__(self, other: Any) -> bool: ... + def __hash__(self) -> int: ... + + def reset(self) -> None: ... + def run(self, runner: ModelRunner, quiet: bool = ..., capture: bool = ...) -> bool: ... + + +class Table(Replayable): + + type: ClassVar[Literal["table"]] + + headings: Sequence[str] + line: int|None + rows: list[Row] + + def __init__(self, headings: Sequence[str], line: int = ..., rows: Sequence[Row] = ...): ... + def __eq__(self, other: Any) -> bool: ... + def __iter__(self) -> Iterator[Row]: ... + def __getitem__(self, index: int) -> Row: ... + + def add_row(self, row: Sequence[str], line: int) -> None: ... + def add_column(self, column_name: str, values: Iterable[str], default_value: str = ...) -> int: ... + def remove_column(self, column_name: str) -> None: ... + def remove_columns(self, column_names: Iterable[str]) -> None: ... + def has_column(self, column_name: str) -> bool: ... + def get_column_index(self, column_name: str) -> int: ... + def require_column(self, column_name: str) -> int: ... + def require_columns(self, column_names: Iterable[str]) -> None: ... + def ensure_column_exists(self, column_name: str) -> int: ... + def assert_equals(self, data: Table|Iterable[Row]) -> None: ... + + +class Row: + + headings: Sequence[str] + cells: Sequence[str] + line: int|None + comments: Sequence[str]|None + + def __init__( + self, + headings: Sequence[str], + cells: Sequence[str], + line: int = ..., + comments: Sequence[str] = ..., + ): ... + def __getitem__(self, index: int) -> str: ... + def __eq__(self, other: Any) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[str]: ... + + def items(self) -> Iterator[tuple[str, str]]: ... + def get(self, key: int, default: str = ...) -> str: ... + def as_dict(self) -> dict[str, str]: ... + + +class Tag(str): + + allowed_chars: ClassVar[str] + quoting_chars: ClassVar[str] + + line: int + + def __init__(self, name: str, line: int): ... + + @classmethod + def make_name(cls, text: str, unexcape: bool = ..., allowed_chars: str = ...) -> str: ... + + +class Text(str): + + content_type: Literal["text/plain"] + line: int + + def __init__(self, value: str, content_type: Literal["text/plain"] = ..., line: int = ...): ... + + def line_range(self) -> tuple[int, int]: ... + def replace(self, old: str, new: str, count: int = ...) -> Text: ... + def assert_equals(self, expected: str) -> bool: ... diff --git a/tests/stubs/behave/model_core.pyi b/tests/stubs/behave/model_core.pyi new file mode 100644 index 0000000..4494916 --- /dev/null +++ b/tests/stubs/behave/model_core.pyi @@ -0,0 +1,123 @@ +from enum import Enum +from types import TracebackType +from typing import Any +from typing import Callable +from typing import ClassVar +from typing import Sequence +from typing import TypeVar + +from .capture import Captured +from .formatter.base import Formatter +from .model import Tag +from .tag_expression import TagExpression + +class Status(Enum): + + untested = 0 + skipped = 1 + passed = 2 + failed = 3 + undefined = 4 + executing = 5 + + @classmethod + def from_name(cls, name: str) -> Status: ... + + +class Argument: + + original: str + value: Any + name: str|None + start: int + end: int + + def __init__(self, start: int, end: int, original: str, value: Any, name: str = ...): ... + + +class FileLocation: + + T = TypeVar("T", bound="FileLocation") + + filename: str + line: int + + def __init__(self, filename: str, line: int): ... + def __eq__(self, other: Any) -> bool: ... + def __ne__(self, other: Any) -> bool: ... + def __le__(self, other: str|FileLocation) -> bool: ... + def __gt__(self, other: str|FileLocation) -> bool: ... + def __ge__(self, other: str|FileLocation) -> bool: ... + def __str__(self) -> str: ... + + def get(self) -> str: ... + def abspath(self) -> str: ... + def basename(self) -> str: ... + def dirname(self) -> str: ... + def relpath(self, start: str = ...) -> str: ... + def exists(self) -> str: ... + + @classmethod + def for_function(cls: type[T], func: Callable[..., Any], curdir: str = ...) -> T: ... + + +class BasicStatement: + + location: FileLocation + keyword: str + name: str + captured: Captured + exception: Exception|None + exc_traceback: TracebackType|None + error_message: str|None + + @property + def filename(self) -> str: ... + + @property + def line(self) -> int: ... + + def __init__(self, filename: str, line: int, keyword: str, name: str): ... + def __hash__(self) -> int: ... + def __eq__(self, other: Any) -> bool: ... + def __ne__(self, other: Any) -> bool: ... + def __lt__(self, other: BasicStatement) -> bool: ... + def __le__(self, other: BasicStatement) -> bool: ... + def __gt__(self, other: BasicStatement) -> bool: ... + def __ge__(self, other: BasicStatement) -> bool: ... + + def reset(self) -> None: ... + def store_exception_context(self, exception: Exception) -> None: ... + + +class TagStatement(BasicStatement): + + tags: Sequence[Tag] + + def __init__(self, filename: str, line: int, keyword: str, name: str, tags: Sequence[Tag]): ... + def should_run_with_tags(self, tag_expression: TagExpression) -> bool: ... + + +class TagAndStatusStatement(BasicStatement): + + final_status: ClassVar[tuple[Status]] + + tags: Sequence[Tag] + should_skip: bool + skip_reason: str|None + + @property + def status(self) -> Status: ... + + def __init__(self, filename: str, line: int, keyword: str, name: str, tags: Sequence[Tag]): ... + + def should_run_with_tags(self, tag_expression: TagExpression) -> bool: ... + def set_status(self, value: Status) -> None: ... + def clear_status(self) -> None: ... + def reset(self) -> None: ... + def compute_status(self) -> Status: ... + + +class Replayable: + + def replay(self, formatter: Formatter) -> None: ... diff --git a/tests/stubs/behave/runner.pyi b/tests/stubs/behave/runner.pyi new file mode 100644 index 0000000..48deb10 --- /dev/null +++ b/tests/stubs/behave/runner.pyi @@ -0,0 +1,148 @@ +import contextlib +from io import StringIO +from typing import Any +from typing import Callable +from typing import Iterator +from typing import Literal +from typing import Protocol +from typing import Sequence +from typing import Union + +from .capture import CaptureController +from .formatter.base import Formatter +from .log_capture import LoggingCapture +from .model import Configuration +from .model import Feature +from .model import Row +from .model import Scenario +from .model import Step +from .model import Table +from .model import Tag +from .model_core import FileLocation +from .step_registry import StepRegistry + +Mode = Union[Literal["behave"], Literal["user"]] + + +@contextlib.contextmanager +def use_context_with_mode(context: Context, mode: Mode) -> Iterator[None]: ... + +@contextlib.contextmanager +def scoped_context_layer(context: Context, layer_name: str|None = None) -> Iterator[Context]: ... + +def path_getrootdir(path: str)-> str: ... + + +class CleanupError(RuntimeError): ... +class ContextMaskWarning(UserWarning): ... + + +class Context(Protocol): + + def __getattr__(self, name: str) -> Any: ... + def __setattr__(self, name: str, value: Any) -> None: ... + def __contains__(self, name: str) -> bool: ... + + def add_cleanup(self, cleanup_func: Callable[..., None], *a: Any, **k: Any) -> None: ... + + @property + def config(self) -> Configuration: ... + + @property + def aborted(self) -> bool: ... + + @property + def failed(self) -> bool: ... + + @property + def log_capture(self) -> LoggingCapture|None: ... + + @property + def stdout_capture(self) -> StringIO|None: ... + + @property + def stderr_capture(self) -> StringIO|None: ... + + @property + def cleanup_errors(self) -> int: ... + + # Feature always present, None outside of feature namespace + + @property + def feature(self) -> Feature|None: ... + + # Step values always present, may be None even in step namespace + + @property + def active_outline(self) -> Row|None: ... + + @property + def table(self) -> Table|None: ... + + @property + def text(self) -> str|None: ... + + +class FeatureContext(Protocol, Context): + + def execute_steps(self, steps_text: str) -> bool: ... + + @property + def feature(self) -> Feature: ... + + @property + def tags(self) -> set[Tag]: ... + + +class ScenarioContext(Protocol, FeatureContext): + + @property + def scenario(self) -> Scenario: ... + + +class Hook(Protocol): + + def __call__(self, context: Context, *args: Any) -> None: ... + + +class ModelRunner: + + config: Configuration + features: Sequence[Feature] + step_registry: StepRegistry + hooks: dict[str, Hook] + formatters: list[Formatter] + undefined_steps: list[Step] + capture_controller: CaptureController + context: Context|None + feature: Feature|None + hook_failures: int + + # is a property in concrete class + aborted: bool + + def __init__( + self, + config: Configuration, + features: Sequence[Feature]|None, + step_registry: StepRegistry|None, + ): ... + + def run_hook(self, name: str, context: Context, *args: Any) -> None: ... + def setup_capture(self) -> None: ... + def start_capture(self) -> None: ... + def stop_capture(self) -> None: ... + def teardown_capture(self) -> None: ... + def run_model(self, features: Sequence[Feature]|None) -> bool: ... + def run(self) -> bool: ... + + +class Runner(ModelRunner): + + def __init__(self, config: Configuration): ... + def setup_paths(self) -> None: ... + def before_all_default_hook(self, context: Context) -> None: ... + def load_hooks(self, filename: str = ...) -> None: ... + def load_step_definitions(self, extra_step_paths: Sequence[str] = ...) -> None: ... + def feature_locations(self) -> list[FileLocation]: ... + def run_with_paths(self) -> bool: ... diff --git a/tests/stubs/behave/step_registry.pyi b/tests/stubs/behave/step_registry.pyi new file mode 100644 index 0000000..8609d1c --- /dev/null +++ b/tests/stubs/behave/step_registry.pyi @@ -0,0 +1,27 @@ +from typing import Callable +from typing import TypeVar + +C = TypeVar("C") +StepDecorator = Callable[[str], Callable[[C], C]] +StepFunction = Callable[..., None] + + +Given: StepDecorator[StepFunction] +given: StepDecorator[StepFunction] + +When: StepDecorator[StepFunction] +when: StepDecorator[StepFunction] + +Then: StepDecorator[StepFunction] +then: StepDecorator[StepFunction] + +Step: StepDecorator[StepFunction] +step: StepDecorator[StepFunction] + + +class AmbiguousStep(ValueError): ... + + +class StepRegistry: + + steps: dict[str, list[StepFunction]] diff --git a/tests/stubs/behave/tag_expression.pyi b/tests/stubs/behave/tag_expression.pyi new file mode 100644 index 0000000..cdb025b --- /dev/null +++ b/tests/stubs/behave/tag_expression.pyi @@ -0,0 +1,22 @@ +from typing import Iterable +from typing import Sequence + +from .model import Tag + +class TagExpression: + + ands: list[tuple[str]] + limits: dict[tuple[str], int] + + def __init__(self, tag_expressions: Iterable[str]): ... + def __len__(self) -> int: ... + + def check(self, tags: Sequence[Tag]) -> bool: ... + + @staticmethod + def normalize_tag(tag: str) -> str: ... + + @classmethod + def normalized_tags_from_or(cls, expr: str) -> Iterable[str]: ... + + def store_and_extract_limits(self, tags: Iterable[str]) -> None: ... diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..8c8fde5 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,29 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +A toolkit of helpful functions and classes for step implementations +""" + +from __future__ import annotations + +from .behave import PatternEnum +from .behave import register_pattern +from .http import redirect +from .json import JSONArray +from .json import JSONObject +from .secret import make_secret +from .url import URL + +__all__ = ( + "JSONArray", + "JSONObject", + "PatternEnum", + "URL", + "make_secret", + "redirect", + "register_pattern", +) diff --git a/tests/utils/behave.py b/tests/utils/behave.py new file mode 100644 index 0000000..5d27832 --- /dev/null +++ b/tests/utils/behave.py @@ -0,0 +1,106 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Utilities for "behave" interactions +""" + +from __future__ import annotations + +import enum +from typing import TYPE_CHECKING +from typing import Any +from typing import Protocol +from typing import TypeVar +from typing import overload + +import behave +import parse + +T = TypeVar("T") + +__all__ = [ + "PatternEnum", + "register_pattern", +] + + +class PatternConverter(Protocol): + + __name__: str + + def __call__(self, match: str) -> Any: ... + + +class Decorator(Protocol[T]): + + def __call__(self, converter: T) -> T: ... + + +@overload +def register_pattern(pattern: str) -> Decorator[PatternConverter]: ... + + +@overload +def register_pattern(pattern: str, converter: PatternConverter) -> PatternConverter: ... + + +def register_pattern(pattern: str, converter: PatternConverter|None = None) -> PatternConverter|Decorator[PatternConverter]: + """ + Register a pattern and converter for a step parser type + + The type is named after the converter. + """ + pattern_decorator = parse.with_pattern(pattern) + + def decorator(converter: PatternConverter) -> PatternConverter: + behave.register_type(**{converter.__name__: pattern_decorator(converter)}) + return converter + + if converter: + return decorator(converter) + return decorator + + +class EnumMeta(enum.EnumMeta): + + MEMBERS = 'members' + + T = TypeVar("T", bound="EnumMeta") + + def __new__(mtc: type[T], name: str, bases: tuple[type, ...], attr: dict[str, Any], **kwds: Any) -> T: + member_names: list[str] = attr._member_names # type: ignore + if mtc.MEMBERS in member_names: + members = attr.pop(mtc.MEMBERS) + member_names.remove(mtc.MEMBERS) + member_names.extend(members) + attr.update(members) + cls = enum.EnumMeta.__new__(mtc, name, bases, attr, **kwds) + decorator = parse.with_pattern('|'.join(member for member in cls.__members__)) + behave.register_type(**{name: decorator(cls)}) + return cls + + +class PatternEnum(enum.Enum, metaclass=EnumMeta): + """ + An enum class that self registers as a pattern type for step implementations + + Enum names are used to match values in step texts, so a value can be aliased multiple + times to provide alternates for matching, including alternative languages. + To supply names that are not valid identifiers the functional Enum API must be used, + supplying mapped values: + https://docs.python.org/3/library/enum.html#functional-api + + Enum values may be anything meaningful; for instance a command keyword that identifies a + type. + """ + + if TYPE_CHECKING: + C = TypeVar("C", bound="PatternEnum") + + @classmethod + def _missing_(cls: type[C], key: Any) -> C: + return cls[key] diff --git a/tests/utils/http.py b/tests/utils/http.py new file mode 100644 index 0000000..b88484c --- /dev/null +++ b/tests/utils/http.py @@ -0,0 +1,70 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Extensions for "requests" +""" + +from __future__ import annotations + +import ipaddress +from typing import Any +from typing import Mapping +from urllib.parse import urlparse + +import requests.adapters +from requests.packages.urllib3 import connection +from requests.packages.urllib3 import connectionpool + + +def redirect(session: requests.Session, prefix: str, address: ipaddress.IPv4Address) -> None: + """ + Redirect all requests for "prefix" to a given address + + This function allows a user to completely override DNS and local name lookups, allowing + fixtures to be contacted via any configured URL without having to mess with the system's + name resolution services. + + "prefix" is formated as either "{hostname}[:{port}]" or "{schema}://{hostname}[:{port}]" + where "schema" defaults to (and currently only supports) "http". + """ + if prefix.startswith("https://"): + raise ValueError("https:// prefixes not currently supported") + if not prefix.startswith("http://"): + prefix = f"http://{prefix}" + session.mount(prefix, LocalHTTPAdapter(address)) + + +class LocalHTTPAdapter(requests.adapters.HTTPAdapter): + """ + An alternative HTTP adapter that directs all connections to a configured address + + Instances of this class are mounted on a `requests.Session` as adapters for specific URL + prefixes. + + Rather than using this class directly the easiest way to use it is with the `redirect` + function. + """ + + def __init__(self, destination: ipaddress.IPv4Address): + super().__init__() + self.destination = destination + + def get_connection(self, url: str, proxies: Mapping[str, str]|None = None) -> _HTTPConnectionPool: + parts = urlparse(url) + return _HTTPConnectionPool(parts.hostname, parts.port, address=self.destination) + + +class _HTTPConnectionPool(connectionpool.HTTPConnectionPool): + + class ConnectionCls(connection.HTTPConnection): + + # Undo the damage done by parent class which makes 'host' a property with magic + host = "" + + def __init__(self, /, address: ipaddress.IPv4Address, **kwargs: Any): + connection.HTTPConnection.__init__(self, **kwargs) + self._dns_host = str(address) diff --git a/tests/utils/json.py b/tests/utils/json.py new file mode 100644 index 0000000..fd9d43b --- /dev/null +++ b/tests/utils/json.py @@ -0,0 +1,68 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +JSON classes for container types (objects and arrays) +""" + +from __future__ import annotations + +import json +from typing import Any +from typing import Callable +from typing import TypeVar +from typing import overload + +from jsonpath import JSONPath + +__all__ = [ + "JSONObject", + "JSONArray", +] + + +class JSONPathMixin: + + T = TypeVar('T', bound=object) + C = TypeVar('C', bound=object) + + @overload + def path(self, path: str, kind: type[T], convert: None = None) -> T: ... + + @overload + def path(self, path: str, kind: type[T], convert: Callable[[T], C]) -> C: ... + + def path(self, path: str, kind: type[T], convert: Callable[[T], C]|None = None) -> T|C: + result = JSONPath(path).parse(self)[0] + if convert is not None: + return convert(result) + elif isinstance(result, kind): + return result + raise ValueError(f"{path} is wrong type; expected {kind}; got {type(result)}") + + +class JSONObject(JSONPathMixin, dict[str, Any]): + """ + A dict for JSON objects that implements `.path` for getting child items by a JSON path + """ + + T = TypeVar("T", bound="JSONObject") + + @classmethod + def from_string(cls: type[T], string: bytes) -> T: + return cls(json.loads(string)) + + +class JSONArray(JSONPathMixin, list[Any]): + """ + A list for JSON arrays that implements `.path` for getting child items by a JSON path + """ + + T = TypeVar("T", bound="JSONArray") + + @classmethod + def from_string(cls: type[T], string: bytes) -> T: + return cls(json.loads(string)) diff --git a/tests/utils/secret.py b/tests/utils/secret.py new file mode 100644 index 0000000..b5caa58 --- /dev/null +++ b/tests/utils/secret.py @@ -0,0 +1,23 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Utility for generating secrets +""" + +from __future__ import annotations + +import string +from secrets import choice + +CHARS = string.ascii_letters + string.digits + + +def make_secret(size: int) -> str: + """ + Generate a string of alphanumeric characters for use as a password + """ + return ''.join(choice(CHARS) for _ in range(size)) diff --git a/tests/utils/url.py b/tests/utils/url.py new file mode 100644 index 0000000..a2db26f --- /dev/null +++ b/tests/utils/url.py @@ -0,0 +1,28 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +URL types and pattern matcher +""" + +from __future__ import annotations + +from urllib.parse import urljoin + +from .behave import register_pattern + + +@register_pattern(r"(?:https?://|/)\S+") +class URL(str): + """ + A subclass for URL strings which also acts as a pattern match type + """ + + def __truediv__(self, other: str) -> URL: + return URL(urljoin(self, other)) + + def __add__(self, other: str) -> URL: + return URL(str(self) + other) diff --git a/tests/wp/__init__.py b/tests/wp/__init__.py new file mode 100644 index 0000000..59d4d39 --- /dev/null +++ b/tests/wp/__init__.py @@ -0,0 +1,177 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Management and control for WordPress fixtures +""" + +from __future__ import annotations + +from contextlib import contextmanager +from subprocess import CalledProcessError +from time import sleep +from time import time +from typing import Any +from typing import Callable +from typing import Iterator +from typing import Literal +from typing import SupportsBytes +from typing import TypeVar +from typing import overload + +from .docker import Container +from .proc import PathArg +from .proc import coerce_args +from .proc import exec_io + + +def wait(predicate: Callable[[], bool], timeout: float = 120.0) -> None: + """ + Block and periodically call "predictate" until it returns True, or the time limit passes + """ + end = time() + timeout + left = timeout + while left > 0.0: + sleep( + 10 if left > 60.0 else + 5 if left > 10.0 else + 1, + ) + left = end - time() + if predicate(): + return + raise TimeoutError + + +class Cli: + """ + Manage calling executables in a container + + Any arguments passed to the constructor will prefix the arguments passed when the object + is called. + """ + + T = TypeVar("T") + + def __init__(self, container: Container, *cmd: PathArg): + self.container = container + self.cmd = cmd + + @overload + def __call__( + self, + *args: PathArg, + input: str|SupportsBytes|None = ..., + deserialiser: Callable[[bytes], T], + query: Literal[False], + **kwargs: Any, + ) -> T: ... + + @overload + def __call__( + self, + *args: PathArg, + input: str|SupportsBytes|None = ..., + deserialiser: None = None, + query: Literal[True], + **kwargs: Any, + ) -> int: ... + + @overload + def __call__( + self, + *args: PathArg, + input: str|SupportsBytes|None = ..., + deserialiser: None = None, + query: Literal[False], + **kwargs: Any, + ) -> None: ... + + def __call__( + self, + *args: PathArg, + input: str|SupportsBytes|None = None, + deserialiser: Callable[[bytes], T]|None = None, + query: bool = False, + **kwargs: Any, + ) -> Any: + # deserialiser = kwargs.pop('deserialiser', None) + assert not deserialiser or not query + + data = ( + b"" if input is None else + input.encode() if isinstance(input, str) else + bytes(input) + ) + cmd = self.container.get_exec_args([*self.cmd, *args], interactive=bool(data)) + + if deserialiser: + return exec_io(cmd, data, deserialiser=deserialiser, **kwargs) + + rcode = exec_io(cmd, data, **kwargs) + if query: + return rcode + if not isinstance(rcode, int): + raise TypeError(f"got rcode {rcode!r}") + if 0 != rcode: + raise CalledProcessError(rcode, ' '.join(coerce_args(cmd))) + return None + + +class Wordpress(Container): + """ + Container subclass for a WordPress PHP-FPM container + """ + + @property + def cli(self) -> Cli: + """ + Run WP-CLI commands + """ + return Cli(self, "wp") + + @contextmanager + def started(self) -> Iterator[Container]: + with self: + self.start() + cmd = ["bash", "-c", "[[ /proc/1/exe -ef `which php-fpm` ]]"] + wait(lambda: self.is_running() and self.run(cmd).returncode == 0, timeout=600) + yield self + + +class Mysql(Container): + """ + Container subclass for a database container + """ + + @property + def mysql(self) -> Cli: + """ + Run "mysql" commands + """ + return Cli(self, "mysql") + + @property + def mysqladmin(self) -> Cli: + """ + Run "mysqladmin" commands + """ + return Cli(self, "mysqladmin") + + @property + def mysqldump(self) -> Cli: + """ + Run "mysqldump" commands + """ + return Cli(self, "mysqldump") + + @contextmanager + def started(self) -> Iterator[Container]: + with self: + self.start() + sleep(20) + wait(lambda: self.run(['/healthcheck.sh']).returncode == 0) + yield self diff --git a/tests/wp/docker.py b/tests/wp/docker.py new file mode 100644 index 0000000..da33626 --- /dev/null +++ b/tests/wp/docker.py @@ -0,0 +1,411 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Commands for managing Docker for fixtures +""" + +from __future__ import annotations + +import ipaddress +import json +from contextlib import contextmanager +from pathlib import Path +from secrets import token_hex +from subprocess import DEVNULL +from subprocess import PIPE +from subprocess import CompletedProcess +from subprocess import Popen +from subprocess import run +from types import TracebackType +from typing import IO +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Iterator +from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union +from typing import overload + +from jsonpath import JSONPath + +from .proc import Arguments +from .proc import Environ +from .proc import MutableArguments +from .proc import PathArg +from .proc import PathLike +from .proc import coerce_args + +T_co = TypeVar('T_co', covariant=True) + +HostMount = tuple[PathLike, PathLike] +NamedMount = tuple[str, PathLike] +AnonMount = PathLike +Mount = Union[HostMount, NamedMount, AnonMount] +Volumes = Iterable[Mount] + +DOCKER = 'docker' + + +def docker(*args: PathArg, **env: str) -> None: + """ + Run a Docker command, with output going to stdout + """ + run([DOCKER, *coerce_args(args)], env=env, check=True) + + +def docker_output(*args: PathArg, **env: str) -> str: + """ + Run a Docker command, capturing and returning its stdout + """ + proc = run([DOCKER, *coerce_args(args)], env=env, check=True, stdout=PIPE, text=True) + return proc.stdout.strip() + + +def docker_quiet(*args: PathArg, **env: str) -> None: + """ + Run a Docker command, directing its stdout to /dev/null + """ + run([DOCKER, *coerce_args(args)], env=env, check=True, stdout=DEVNULL) + + +class IPv4Address(ipaddress.IPv4Address): + """ + Subclass of IPv4Address that handle's docker idiosyncratic tendency to add a mask suffix + """ + + T = TypeVar("T", bound="IPv4Address") + + @classmethod + def with_suffix(cls: type[T], address: str) -> T: + """ + Construct an instance with a suffixed bitmask size + """ + address, *_ = address.partition("/") + return cls(address) + + +class Item: + """ + A mix-in for Docker items that can be inspected + """ + + T = TypeVar('T', bound=object) + C = TypeVar('C', bound=object) + + _data: Optional[dict[str, Any]] = None + + def __init__(self, name: str): + self.name = name + self._data: Any = None + + def get_id(self) -> str: + """ + Return an identifier for the Docker item + """ + return self.name + + @overload + def inspect(self, path: str, kind: type[T], convert: None = None) -> T: ... + + @overload + def inspect(self, path: str, kind: type[T], convert: Callable[[T], C]) -> C: ... + + def inspect(self, path: str, kind: type[T], convert: Callable[[T], C]|None = None) -> T|C: + """ + Extract a value from an item's information by JSON path + + "kind" is the type of the extracted value, while "convert" is an optional callable + that can turn that type to another type. The return type will be the return type of + "convert" if provided, "kind" otherwise. + """ + if self._data is None: + with Popen([DOCKER, 'inspect', self.get_id()], stdout=PIPE) as proc: + assert proc.stdout is not None + results = json.load(proc.stdout) + assert isinstance(results, list) + assert len(results) == 1 and isinstance(results[0], dict) + self._data = results[0] + result = JSONPath(path).parse(self._data) + if "*" not in path: + try: + result = result[0] + except IndexError: + raise KeyError(path) from None + if not isinstance(result, kind): + raise TypeError(f"{path} is wrong type; expected {kind}; got {type(result)}") + if convert is None: + return result + return convert(result) + + +class Image(Item): + """ + Docker image items + """ + + T = TypeVar('T', bound='Image') + + def __init__(self, iid: str): + self.iid = iid + + @classmethod + def build(cls: type[T], context: Path, target: str = "", **build_args: str) -> T: + """ + Build an image from the given context + """ + cmd: Arguments = [ + 'build', context, f"--target={target}", + *(f"--build-arg={arg}={val}" for arg, val in build_args.items()), + ] + docker(*cmd, DOCKER_BUILDKIT='1') + iid = docker_output(*cmd, '-q', DOCKER_BUILDKIT='1') + return cls(iid) + + @classmethod + def pull(cls: type[T], repository: str) -> T: + """ + Pull an image from a registry + """ + docker('pull', repository) + iid = Item(repository).inspect('$.Id', str) + return cls(iid) + + def get_id(self) -> str: + return self.iid + + +class Container(Item): + """ + Docker container items + + Instances can be used as context managers that ensure the container is stopped on + exiting the context. + """ + + DEFAULT_ALIASES = tuple[str]() + + def __init__( + self, + image: Image, + cmd: Arguments = [], + volumes: Volumes = [], + env: Environ = {}, + network: Network|None = None, + entrypoint: HostMount|PathArg|None = None, + ): + if isinstance(entrypoint, tuple): + volumes = [*volumes, entrypoint] + entrypoint = entrypoint[1] + + self.image = image + self.cmd = cmd + self.volumes = volumes + self.env = env + self.entrypoint = entrypoint + self.networks = dict[Network, Tuple[str, ...]]() + self._id: str|None = None + + if network: + self.networks[network] = Container.DEFAULT_ALIASES + + def __enter__(self) -> Container: + return self + + def __exit__(self, etype: type[BaseException], exc: BaseException, tb: TracebackType) -> None: + if self._id and exc: + self.show_logs() + self.stop(rm=True) + + @contextmanager + def started(self) -> Iterator[Container]: + """ + A context manager that ensures the container is started when the context is entered + """ + with self: + self.start() + yield self + + def is_running(self) -> bool: + """ + Return whether the container is running + """ + if self._id is None: + return False + item = Item(self._id) + if item.inspect('$.State.Status', str) == 'exited': + code = item.inspect('$.State.ExitCode', int) + raise ProcessLookupError(f"container {self._id} exited ({code})") + return ( + self._id is not None + and item.inspect('$.State.Running', bool) + ) + + def get_id(self) -> str: + if self._id is not None: + return self._id + + networks = set[Network]() + opts: MutableArguments = [ + *( + (f"--volume={vol[0]}:{vol[1]}" if isinstance(vol, tuple) else f"--volume={vol}") + for vol in self.volumes + ), + *(f"--env={name}={val}" for name, val in self.env.items()), + ] + + if self.entrypoint: + opts.append(f"--entrypoint={self.entrypoint}") + if self.networks: + networks.update(self.networks) + net = networks.pop() + opts.append(f"--network={net}") + opts.extend(f"--network-alias={alias}" for alias in self.networks[net]) + + self._id = docker_output('container', 'create', *opts, self.image.iid, *self.cmd) + assert self._id + return self._id + + def start(self) -> None: + """ + Start the container + """ + docker_quiet('container', 'start', self.get_id()) + + def stop(self, rm: bool = False) -> None: + """ + Stop the container + """ + if self._id is None: + return + docker_quiet('container', 'stop', self._id) + if rm: + docker_quiet('container', 'rm', self._id) + self._id = None + + def connect(self, network: Network, *aliases: str) -> None: + """ + Connect the container to a Docker network + + Any aliases supplied will be resolvable to the container by other containers on the + network. + """ + is_running = self.is_running() + if network in self.networks: + if self.networks[network] == aliases: + return + if is_running: + docker('network', 'disconnect', str(network), self.get_id()) + if is_running: + docker( + 'network', 'connect', + *(f'--alias={a}' for a in aliases), + str(network), self.get_id(), + ) + self.networks[network] = aliases + + def show_logs(self) -> None: + """ + Print the container logs to stdout + """ + if self._id: + docker('logs', self._id) + + def get_exec_args(self, cmd: Arguments, interactive: bool = False) -> MutableArguments: + """ + Return a full argument list for running "cmd" inside the container + """ + return [DOCKER, "exec", *(("-i",) if interactive else ""), self.get_id(), *coerce_args(cmd)] + + def run( + self, + cmd: Arguments, + *, + stdin: IO[Any]|int|None = None, + stdout: IO[Any]|int|None = None, + stderr: IO[Any]|int|None = None, + capture_output: bool = False, + check: bool = False, + input: bytes|None = None, + timeout: float|None = None, + ) -> CompletedProcess[bytes]: + """ + Run "cmd" to completion inside the container and return the result + """ + return run( + self.get_exec_args(cmd), + stdin=stdin, stdout=stdout, stderr=stderr, + capture_output=capture_output, + check=check, timeout=timeout, input=input, + ) + + def exec( + self, + cmd: Arguments, + *, + stdin: IO[Any]|int|None = None, + stdout: IO[Any]|int|None = None, + stderr: IO[Any]|int|None = None, + ) -> Popen[bytes]: + """ + Execute "cmd" inside the container and return a process object once started + """ + return Popen( + self.get_exec_args(cmd), + stdin=stdin, stdout=stdout, stderr=stderr, + ) + + +class Network: + """ + A Docker network + """ + + def __init__(self, name: str|None = None) -> None: + self._name = name or f"br{token_hex(6)}" + + def __str__(self) -> str: + return self._name + + def __repr__(self) -> str: + cls = type(self) + return f"<{cls.__module__}.{cls.__name__} {self._name}>" + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Network): + return self._name == str(other) + return self._name == other._name + + def __hash__(self) -> int: + return self._name.__hash__() + + def __enter__(self) -> Network: + self.create() + return self + + def __exit__(self, etype: type[BaseException], exc: BaseException, tb: TracebackType) -> None: + self.destroy() + + @property + def name(self) -> str: + return self._name + + def get_id(self) -> str: + return self._name + + def create(self) -> None: + """ + Create the network + """ + docker_quiet("network", "create", self._name) + + def destroy(self) -> None: + """ + Remove the network + """ + docker_quiet("network", "rm", self._name) diff --git a/tests/wp/proc.py b/tests/wp/proc.py new file mode 100644 index 0000000..e368af6 --- /dev/null +++ b/tests/wp/proc.py @@ -0,0 +1,158 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Manage processes asynchronously +""" + +from __future__ import annotations + +import io +import logging +import os +import sys +from subprocess import DEVNULL +from subprocess import PIPE +from typing import IO +from typing import Any +from typing import Callable +from typing import Iterator +from typing import Mapping +from typing import MutableSequence +from typing import Sequence +from typing import TypeVar +from typing import Union +from typing import overload + +import trio.abc + +T = TypeVar('T') +Deserialiser = Callable[[bytes], T] + +PathLike = os.PathLike[str] +PathArg = Union[PathLike, str] +Arguments = Sequence[PathArg] +MutableArguments = MutableSequence[PathArg] +Environ = Mapping[str, str] + +_logger = logging.getLogger(__name__) + + +def coerce_args(args: Arguments) -> Iterator[str]: + """ + Ensure path-like arguments are converted to strings + """ + return (os.fspath(a) for a in args) + + +@overload +def exec_io( + cmd: Arguments, + data: bytes = b'', + deserialiser: Deserialiser[T] = ..., + **kwargs: Any, +) -> T: ... + + +@overload +def exec_io( + cmd: Arguments, + data: bytes = b'', + deserialiser: None = None, + **kwargs: Any, +) -> int: ... + + +def exec_io( + cmd: Arguments, + data: bytes = b'', + deserialiser: Deserialiser[Any]|None = None, + **kwargs: Any, +) -> Any: + """ + Execute a command, handling output asynchronously + + If data is provided it will be fed to the process' stdin. + If a deserialiser is provided it will be used to parse stdout data from the process. + + Stderr and stdout (if no deserialiser is provided) will be written to `sys.stderr` and + `sys.stdout` respectively. + + Note that the data is written, not redirected. If either `sys.stdout` or `sys.stderr` + is changed to an IO-like object with no file descriptor, this will still work. + """ + if deserialiser and 'stdout' in kwargs: + raise TypeError("Cannot provide 'deserialiser' with 'stdout' argument") + if data and 'stdin' in kwargs: + raise TypeError("Cannot provide 'data' with 'stdin' argument") + stdout: IO[str]|IO[bytes]|int = io.BytesIO() if deserialiser else kwargs.pop('stdout', sys.stdout) + stderr: IO[str]|IO[bytes]|int = kwargs.pop('stderr', sys.stderr) + _logger.debug("executing: %s", cmd) + proc = trio.run(_exec_io, cmd, data, stdout, stderr, kwargs) + if deserialiser: + assert isinstance(stdout, io.BytesIO) + return deserialiser(stdout.getvalue()) + return proc.returncode + + +async def _exec_io( + cmd: Arguments, + data: bytes, + stdout: IO[str]|IO[bytes]|int, + stderr: IO[str]|IO[bytes]|int, + kwargs: dict[str, Any], +) -> trio.Process: + proc = await trio.open_process( + [*coerce_args(cmd)], + stdin=PIPE if data else DEVNULL, + stdout=PIPE, + stderr=PIPE, + **kwargs, + ) + async with proc, trio.open_nursery() as nursery: + assert proc.stdout is not None and proc.stderr is not None + nursery.start_soon(_passthru, proc.stderr, stderr) + nursery.start_soon(_passthru, proc.stdout, stdout) + if data: + assert proc.stdin is not None + async with proc.stdin as stdin: + await stdin.send_all(data) + return proc + + +async def _passthru(in_stream: trio.abc.ReceiveStream, out_stream: IO[str]|IO[bytes]|int) -> None: + try: + if not isinstance(out_stream, int): + out_stream = out_stream.fileno() + except (OSError, AttributeError): + # cannot get file descriptor, probably a memory buffer + if isinstance(out_stream, io.BytesIO): + async def write(data: bytes) -> None: + assert isinstance(out_stream, io.BytesIO) + out_stream.write(data) + elif isinstance(out_stream, io.StringIO): + async def write(data: bytes) -> None: + assert isinstance(out_stream, io.StringIO) + out_stream.write(data.decode()) + else: + raise TypeError(f"Unknown IO type: {type(out_stream)}") + else: + # is/has a file descriptor, out_stream is now that file descriptor + async def write(data: bytes) -> None: + assert isinstance(out_stream, int) + data = memoryview(data) + remaining = len(data) + while remaining: + await trio.lowlevel.wait_writable(out_stream) + written = os.write(out_stream, data) + data = data[written:] + remaining -= written + + while True: + data = await in_stream.receive_some() + if not data: + return + await write(data) -- GitLab From af5625fab7e78a901fe92545a47511213c8142dc Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Mon, 18 Oct 2021 22:06:25 +0100 Subject: [PATCH 04/12] Modify PatternEnum subclasses' members with a method Replace static additional members dict with a method for programmatic modifications. --- tests/steps/request_steps.py | 28 +++++++++++++++++++++++++--- tests/utils/behave.py | 11 +++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py index ca489b9..b39b232 100644 --- a/tests/steps/request_steps.py +++ b/tests/steps/request_steps.py @@ -10,6 +10,8 @@ Step implementations dealing with HTTP requests from __future__ import annotations +from typing import Any + from behave import then from behave import when from behave.runner import Context @@ -25,11 +27,31 @@ class ResponseCode(int, PatternEnum): ok = 200 not_found = 404 - members = { - "200": 200, "OK": 200, - "404": 404, "Not Found": 404, + # Aliases for the above codes, for mapping natural language in feature files to enums + ALIASES = { + "OK": 200, + "Not Found": 404, } + @staticmethod + def member_filter(attr: dict[str, Any], member_names: list[str]) -> None: + """ + Add natural language aliases and stringified code values to members + + Most will be accessible only though a class call, which is acceptable as that is how + step implementations look up the values. + """ + additional = { + str(value): value + for name in member_names + for value in [attr[name]] + if isinstance(value, int) + } + additional.update(attr["ALIASES"]) + member_names.remove("ALIASES") + member_names.extend(additional) + attr.update(additional) + @when("{url:URL} is requested") def get_request(context: Context, url: URL) -> None: diff --git a/tests/utils/behave.py b/tests/utils/behave.py index 5d27832..8f6d550 100644 --- a/tests/utils/behave.py +++ b/tests/utils/behave.py @@ -67,17 +67,16 @@ def register_pattern(pattern: str, converter: PatternConverter|None = None) -> P class EnumMeta(enum.EnumMeta): - MEMBERS = 'members' + MEMBER_FILTER = 'member_filter' T = TypeVar("T", bound="EnumMeta") def __new__(mtc: type[T], name: str, bases: tuple[type, ...], attr: dict[str, Any], **kwds: Any) -> T: member_names: list[str] = attr._member_names # type: ignore - if mtc.MEMBERS in member_names: - members = attr.pop(mtc.MEMBERS) - member_names.remove(mtc.MEMBERS) - member_names.extend(members) - attr.update(members) + member_filter = attr.pop(mtc.MEMBER_FILTER, None) + if member_filter: + assert isinstance(member_filter, staticmethod) + member_filter.__func__(attr, member_names) cls = enum.EnumMeta.__new__(mtc, name, bases, attr, **kwds) decorator = parse.with_pattern('|'.join(member for member in cls.__members__)) behave.register_type(**{name: decorator(cls)}) -- GitLab From 6a2f9b5e8e80d1e49d02693d976edf9e4eab138d Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Mon, 18 Oct 2021 23:35:30 +0100 Subject: [PATCH 05/12] Update Mypy config with tests/ packages path --- .lint.cfg | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.lint.cfg b/.lint.cfg index f560ed0..c56b8d6 100644 --- a/.lint.cfg +++ b/.lint.cfg @@ -5,8 +5,7 @@ force_single_line = true strict = true warn_unused_configs = true warn_unreachable = true -;implicit_reexport = true -mypy_path = tests/stubs +mypy_path = tests, tests/stubs plugins = trio_typing.plugin -- GitLab From c2db52ea94d4121114eaa50b8d94f7f4b7f20392 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Mon, 18 Oct 2021 23:39:23 +0100 Subject: [PATCH 06/12] Add useful messages to step assertions --- tests/steps/pages.py | 3 ++- tests/steps/request_steps.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/steps/pages.py b/tests/steps/pages.py index 9feeaee..e475171 100644 --- a/tests/steps/pages.py +++ b/tests/steps/pages.py @@ -54,7 +54,8 @@ def assert_not_exist(context: Context, path: str) -> None: "--post_type=post,page", "--post_status=publish", ] urls = {*context.site.backend.cli(*cmd, deserialiser=JSONArray.from_string)} - assert context.site.url / path not in urls + assert context.site.url / path not in urls, \ + f"{context.site.url / path} exists" @given("a {post_type:PostType} exists containing") diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py index b39b232..02c36e4 100644 --- a/tests/steps/request_steps.py +++ b/tests/steps/request_steps.py @@ -77,4 +77,5 @@ def assert_response(context: Context, response: ResponseCode) -> None: "response" can be a numeric or phrasal response in ResponseCode """ - assert context.response.status_code == response + assert context.response.status_code == response, \ + f"Expected response {response}: got {context.response.status_code}" -- GitLab From b957a761043eacebfdd6cb14fc202a31df616230 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Mon, 18 Oct 2021 23:43:41 +0100 Subject: [PATCH 07/12] Fix URL regex --- tests/utils/url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/url.py b/tests/utils/url.py index a2db26f..2fc6f9b 100644 --- a/tests/utils/url.py +++ b/tests/utils/url.py @@ -15,7 +15,7 @@ from urllib.parse import urljoin from .behave import register_pattern -@register_pattern(r"(?:https?://|/)\S+") +@register_pattern(r"(?:https?://\S+|/\S*)") class URL(str): """ A subclass for URL strings which also acts as a pattern match type -- GitLab From 2589fe2b8bbec6f29187ff32af4f30b8b9ef1962 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Wed, 20 Oct 2021 19:49:47 +0100 Subject: [PATCH 08/12] Disable redirect following in tests Tests should check the response to the original request. --- tests/regression-14.feature | 2 +- tests/steps/request_steps.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/regression-14.feature b/tests/regression-14.feature index f5a383e..0a96f03 100644 --- a/tests/regression-14.feature +++ b/tests/regression-14.feature @@ -22,6 +22,6 @@ Feature: Return 404 for unknown path Examples: | suffix | result | - | / | OK | + | / | 301 | | /0 | OK | | /foo | Not Found | diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py index 02c36e4..8cc15bb 100644 --- a/tests/steps/request_steps.py +++ b/tests/steps/request_steps.py @@ -25,6 +25,11 @@ class ResponseCode(int, PatternEnum): """ ok = 200 + moved_permanently = 301 + found = 302 + not_modified = 304 + temporary_redirect = 307 + permanent_redirect = 308 not_found = 404 # Aliases for the above codes, for mapping natural language in feature files to enums @@ -58,7 +63,7 @@ def get_request(context: Context, url: URL) -> None: """ Assign the response from making a GET request to "url" to the context """ - context.response = context.session.get(context.site.url / url) + context.response = context.session.get(context.site.url / url, allow_redirects=False) @when("the homepage is requested") -- GitLab From 2abb1b06e3bcaac8a61b3c03582327838b6698e1 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Thu, 21 Oct 2021 01:56:13 +0100 Subject: [PATCH 09/12] Add further tests for various types of page --- tests/home-page.feature | 22 --------------- tests/pages.feature | 19 +++++++++++++ tests/posts.feature | 25 ++++++++++++++++ tests/script-access.feature | 54 +++++++++++++++++++++++++++++++++++ tests/steps/pages.py | 55 ++++++++++++++++++++++++++++-------- tests/steps/request_steps.py | 12 ++++++++ 6 files changed, 153 insertions(+), 34 deletions(-) delete mode 100644 tests/home-page.feature create mode 100644 tests/pages.feature create mode 100644 tests/posts.feature create mode 100644 tests/script-access.feature diff --git a/tests/home-page.feature b/tests/home-page.feature deleted file mode 100644 index 4b3059d..0000000 --- a/tests/home-page.feature +++ /dev/null @@ -1,22 +0,0 @@ -Feature: Homepage - The homepage should display either a static page or a feed - - Scenario: static homepage - Given a page exists containing - """ - This is some page content - """ - And the page is configured as the homepage - When the homepage is requested - Then OK is returned - And we will see the page text - - Scenario: feed homepage - Given a post exists containing - """ - This is some post content - """ - And the homepage is the default - When the homepage is requested - Then OK is returned - And we will see the post text diff --git a/tests/pages.feature b/tests/pages.feature new file mode 100644 index 0000000..49fa565 --- /dev/null +++ b/tests/pages.feature @@ -0,0 +1,19 @@ +Feature: Pages + Pages should be returned when their URL is requested. + + Background: A page exists + Given a page exists containing + """ + This is some page content + """ + + Scenario: A page type post + When the page is requested + Then OK is returned + And we will see the page text + + Scenario: Static homepage + Given the page is configured as the homepage + When the homepage is requested + Then OK is returned + And we will see the page text diff --git a/tests/posts.feature b/tests/posts.feature new file mode 100644 index 0000000..cf92e5a --- /dev/null +++ b/tests/posts.feature @@ -0,0 +1,25 @@ +Feature: Posts + Posts should be returned when their URL is requested. The post index page + should also be accessible. + + Background: A post exists + Given a post exists containing + """ + This is some page content + """ + + Scenario: Individual posts + When the post is requested + Then OK is returned + And we will see the post text + + Scenario: Homepage post index + When the homepage is requested + Then OK is returned + And we will see the post text + + Scenario: Non-homepage post index + Given a blank page exists + And is configured as the post index + When the page is requested + Then we will see the post text diff --git a/tests/script-access.feature b/tests/script-access.feature new file mode 100644 index 0000000..4e3a3b3 --- /dev/null +++ b/tests/script-access.feature @@ -0,0 +1,54 @@ +Feature: Script Access and Restrictions + The user-facing parts of a WordPress application should all be either static + resources or channeled through the root *index.php* entrypoint. However PHP + is architectured in such a way that, if left unrestricted, any PHP file + could be accessed as a script. + + In many cases protections have been put in place in WordPress' PHP files to + prevent circumvention of access restriction, as well as some plugins. + However this should never be relied on as it introduces additional + complexity and may not have been thoroughly tested. It could also be + considered a UI bug if a non-404 code is returned. + + To confuse matters the administration interface *is* accessed in a + one-script-per-endpoint manner. + + Scenario Outline: Direct file access + When is requested + Then is returned + + Examples: Static files + | path | result | + | /wp-includes/images/w-logo-blue.png | OK | + | /wp-admin/images/w-logo-blue.png | OK | + | /readme.html | Not Found | + | /composer.json | Not Found | + | /composer.lock | Not Found | + + Examples: Non-entrypoint PHP files + | path | result | + | /wp-activate.php | Not Found | + | /wp-blog-header.php | Not Found | + | /wp-comments-post.php | Not Found | + | /wp-config.php | Not Found | + | /wp-cron.php | Not Found | + | /wp-load.php | Not Found | + | /wp-mail.php | Not Found | + | /wp-settings.php | Not Found | + | /wp-signup.php | Not Found | + | /wp-trackback.php | Not Found | + | /xmlrpc.php | Not Found | + | /wp-includes/user.php | Not Found | + + Examples: Entrypoint PHP files + | path | result | + | / | OK | + | /index.php | 301 | + | /wp-login.php | OK | + | /wp-admin/ | 302 | + | /wp-admin/index.php | 302 | + + Scenario: Check the JSON API is accessible + When /wp-json/wp/v2/ is requested + Then OK is returned + And the response body is JSON diff --git a/tests/steps/pages.py b/tests/steps/pages.py index e475171..151e78c 100644 --- a/tests/steps/pages.py +++ b/tests/steps/pages.py @@ -58,12 +58,13 @@ def assert_not_exist(context: Context, path: str) -> None: f"{context.site.url / path} exists" +@given("a blank {post_type:PostType} exists") @given("a {post_type:PostType} exists containing") def create_post(context: Context, post_type: PostType, text: str|None = None) -> None: """ Create a WP post of the given type and store it in the context with the type as the name """ - post = use_fixture(wp_post, context, post_type, text or context.text) + post = use_fixture(wp_post, context, post_type, text or getattr(context, "text", "")) setattr(context, post_type.value, post) @@ -72,21 +73,15 @@ def set_homepage(context: Context) -> None: """ Set the WP page from the context as the configured front page """ - wp = context.site.backend - pageid = context.page.path("$.ID", int) - wp.cli("option", "update", "page_on_front", str(pageid)) - wp.cli("option", "update", "show_on_front", "page") - - page = use_fixture(wp_post, context, PostType.page) - wp.cli("option", "update", "page_for_posts", page.path("$.ID", int, str)) + use_fixture(set_specials, context, homepage=context.page) -@given("the homepage is the default") -def reset_homepage(context: Context) -> None: +@given("is configured as the post index") +def set_post_index(context: Context) -> None: """ - Ensure the front page is reverted to it's default + Set the WP page from the context as the post index page """ - context.site.backend.cli("option", "update", "show_on_front", "post") + use_fixture(set_specials, context, posts=context.page) @when("the {post_type:PostType} is requested") @@ -160,3 +155,39 @@ def wp_post( ) yield post wp.cli("post", "delete", postid) + + +@fixture +def set_specials( + context: Context, /, + homepage: JSONObject|None = None, + posts: JSONObject|None = None, + *a: Any, + **k: Any, +) -> Iterator[None]: + """ + Set the homepage and post index to new pages, creating default pages if needed + + Pages are reset at the end of a scenario + """ + wp = context.site.backend + + options = { + opt["option_name"]: opt["option_value"] + for opt in wp.cli("option", "list", "--format=json", deserialiser=JSONArray.from_string) + } + + homepage = homepage or use_fixture(wp_post, context, PostType.page) + wp.cli("option", "update", "page_on_front", homepage.path("$.ID", int, str)) + wp.cli("option", "update", "show_on_front", "page") + + posts = posts or use_fixture(wp_post, context, PostType.page) + wp.cli("option", "update", "page_for_posts", posts.path("$.ID", int, str)) + + yield + + for name in ["page_on_front", "show_on_front", "page_for_posts"]: + try: + wp.cli("option", "update", name, options[name]) + except KeyError: + wp.cli("option", "delete", name) diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py index 8cc15bb..cd4c1f5 100644 --- a/tests/steps/request_steps.py +++ b/tests/steps/request_steps.py @@ -10,6 +10,7 @@ Step implementations dealing with HTTP requests from __future__ import annotations +import json from typing import Any from behave import then @@ -84,3 +85,14 @@ def assert_response(context: Context, response: ResponseCode) -> None: """ assert context.response.status_code == response, \ f"Expected response {response}: got {context.response.status_code}" + + +@then("the response body is JSON") +def assert_is_json(context: Context) -> None: + """ + Assert the response body of a previous step contains a JSON document + """ + try: + context.response.json() + except json.JSONDecodeError: + raise AssertionError("Response is not a JSON document") -- GitLab From 264db87b6b761688c21e92bdfe3733571ed59f7e Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Fri, 22 Oct 2021 00:12:22 +0100 Subject: [PATCH 10/12] Add build-arguments to tests/environment.py --- tests/environment.py | 14 +++++++++++--- tests/wp/docker.py | 7 +++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/environment.py b/tests/environment.py index b871126..ee5727f 100644 --- a/tests/environment.py +++ b/tests/environment.py @@ -16,6 +16,7 @@ from __future__ import annotations import sys from contextlib import contextmanager +from os import environ from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -140,7 +141,11 @@ def test_cluster(site_url: str) -> Iterator[Site]: ), ) frontend = Container( - Image.build(BUILD_CONTEXT, target='nginx'), + Image.build( + BUILD_CONTEXT, + target='nginx', + nginx_version=environ.get("NGINX_VERSION"), + ), network=network, volumes=[ ("static", Path("/app/static")), @@ -148,7 +153,11 @@ def test_cluster(site_url: str) -> Iterator[Site]: ], ) backend = Wordpress( - Image.build(BUILD_CONTEXT), + Image.build( + BUILD_CONTEXT, + php_version=environ.get("PHP_VERSION"), + wp_version=environ.get("WP_VERSION"), + ), network=network, volumes=frontend.volumes, env=dict( @@ -173,7 +182,6 @@ def test_cluster(site_url: str) -> Iterator[Site]: if __name__ == "__main__": - from os import environ from subprocess import run with test_cluster(SITE_URL) as site: diff --git a/tests/wp/docker.py b/tests/wp/docker.py index da33626..9a28d3c 100644 --- a/tests/wp/docker.py +++ b/tests/wp/docker.py @@ -155,13 +155,16 @@ class Image(Item): self.iid = iid @classmethod - def build(cls: type[T], context: Path, target: str = "", **build_args: str) -> T: + def build(cls: type[T], context: Path, target: str = "", **build_args: str|None) -> T: """ Build an image from the given context + + Build arguments are ignored if they are None to make it easier to supply (or not) + arguments from external lookups without complex argument composing. """ cmd: Arguments = [ 'build', context, f"--target={target}", - *(f"--build-arg={arg}={val}" for arg, val in build_args.items()), + *(f"--build-arg={arg}={val}" for arg, val in build_args.items() if val is not None), ] docker(*cmd, DOCKER_BUILDKIT='1') iid = docker_output(*cmd, '-q', DOCKER_BUILDKIT='1') -- GitLab From 8e6cb60b7981f9522d2e65de08da3fa506d08c91 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Sun, 24 Oct 2021 17:26:54 +0100 Subject: [PATCH 11/12] Add an optional name argument to register_pattern This allows third-party converters with too generic names to be added with more useful names. --- tests/utils/behave.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/utils/behave.py b/tests/utils/behave.py index 8f6d550..765b62a 100644 --- a/tests/utils/behave.py +++ b/tests/utils/behave.py @@ -45,10 +45,18 @@ def register_pattern(pattern: str) -> Decorator[PatternConverter]: ... @overload -def register_pattern(pattern: str, converter: PatternConverter) -> PatternConverter: ... - - -def register_pattern(pattern: str, converter: PatternConverter|None = None) -> PatternConverter|Decorator[PatternConverter]: +def register_pattern( + pattern: str, + converter: PatternConverter, + name: str = ..., +) -> PatternConverter: ... + + +def register_pattern( + pattern: str, + converter: PatternConverter|None = None, + name: str = "", +) -> PatternConverter|Decorator[PatternConverter]: """ Register a pattern and converter for a step parser type @@ -57,7 +65,9 @@ def register_pattern(pattern: str, converter: PatternConverter|None = None) -> P pattern_decorator = parse.with_pattern(pattern) def decorator(converter: PatternConverter) -> PatternConverter: - behave.register_type(**{converter.__name__: pattern_decorator(converter)}) + nonlocal name + name = name or converter.__name__ + behave.register_type(**{name: pattern_decorator(converter)}) return converter if converter: -- GitLab From fe7865490cf91f850bc98526375ded9737d02f15 Mon Sep 17 00:00:00 2001 From: Dom Sekotill Date: Sun, 24 Oct 2021 17:50:05 +0100 Subject: [PATCH 12/12] Add WP-CLI tests --- tests/steps/commands.py | 92 +++++++++++++++++++++++++++++++++++++++++ tests/wp-cli.feature | 30 ++++++++++++++ tests/wp/__init__.py | 2 +- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/steps/commands.py create mode 100644 tests/wp-cli.feature diff --git a/tests/steps/commands.py b/tests/steps/commands.py new file mode 100644 index 0000000..897f21e --- /dev/null +++ b/tests/steps/commands.py @@ -0,0 +1,92 @@ +# Copyright 2021 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Step implementations involving running commands in fixture containers +""" + +from __future__ import annotations + +import json +import shlex +from typing import TYPE_CHECKING + +from behave import then +from behave import when +from utils.behave import PatternEnum +from utils.behave import register_pattern +from wp import Container + +if TYPE_CHECKING: + from behave.runner import Context + + +@register_pattern(r".+") +class Arguments(list[str]): + """ + Step pattern for command lines + """ + + def __init__(self, cmdline: str): + self.extend(shlex.split(cmdline)) + + +class Stream(PatternEnum): + """ + Pattern matching enum for stdio stream names + """ + + STDOUT = "stdout" + STDERR = "stderr" + + stdout = STDOUT + stderr = STDERR + + +@when(""""{args:Arguments}" is run""") +@when("""'{args:Arguments}' is run""") +def run_command(context: Context, args: Arguments) -> None: + """ + Run a command in the appropriate site container + """ + if len(args) == 0: + raise ValueError("No arguments in argument list") + if args[0] in ('wp', 'php'): + container: Container = context.site.backend + else: + raise ValueError(f"Unknown command: {args[0]}") + context.process = container.run(args, capture_output=True) + + +@then("nothing is seen from {stream:Stream}") +def check_empty_stream(context: Context, stream: Stream) -> None: + """ + Check there is no output on the given stream of a previous command + """ + output = getattr(context.process, stream.value) + assert not output, f"Unexpected output seen from {stream.name}: {output}" + + +@then("JSON is seen from {stream:Stream}") +def check_json_stream(context: Context, stream: Stream) -> None: + """ + Check there is no output on the given stream of a previous command + """ + output = getattr(context.process, stream.value) + try: + json.loads(output) + except json.JSONDecodeError: + raise AssertionError(f"Expecting JSON from {stream.name}; got {output}") + + +@then('"{response}" is seen from {stream:Stream}') +def check_stream(context: Context, response: str, stream: Stream) -> None: + """ + Check the output streams of a previous command for the given response + """ + output = getattr(context.process, stream.value) + assert output.strip() == response.encode(), \ + f"Expected output from {stream.name}: {response.encode()!r}\ngot: {output!r}" diff --git a/tests/wp-cli.feature b/tests/wp-cli.feature new file mode 100644 index 0000000..24a514e --- /dev/null +++ b/tests/wp-cli.feature @@ -0,0 +1,30 @@ +Feature: WP-CLI management tool + + Scenario Outline: Setting test commands + When "" is run + Then nothing is seen from stderr + + Examples: + | cmd | + | wp option update timezone_string Europe/London | + + + Scenario Outline: Getting test commands + When "" is run + Then "" is seen from stdout + And nothing is seen from stderr + + Examples: + | cmd | response | + | wp option get timezone_string | Europe/London | + + + Scenario Outline: Getting JSON test commands + When "" is run + Then JSON is seen from stdout + And nothing is seen from stderr + + Examples: + | cmd | + | wp option get timezone_string --format=json | + | wp theme list --format=json | diff --git a/tests/wp/__init__.py b/tests/wp/__init__.py index 59d4d39..8a6f487 100644 --- a/tests/wp/__init__.py +++ b/tests/wp/__init__.py @@ -22,7 +22,7 @@ from typing import SupportsBytes from typing import TypeVar from typing import overload -from .docker import Container +from .docker import Container as Container from .proc import PathArg from .proc import coerce_args from .proc import exec_io -- GitLab