├── src
└── feditest
│ ├── protocols
│ ├── __init__.py
│ ├── fediverse
│ │ ├── utils.py
│ │ └── diag.py
│ ├── web
│ │ └── __init__.py
│ ├── activitypub
│ │ ├── utils.py
│ │ ├── __init__.py
│ │ └── diag.py
│ ├── sandbox
│ │ └── __init__.py
│ └── webfinger
│ │ ├── abstract.py
│ │ └── __init__.py
│ ├── testruntranscriptserializer
│ ├── templates
│ │ └── testplantranscript_default
│ │ │ ├── partials
│ │ │ ├── shared
│ │ │ │ ├── head.jinja2
│ │ │ │ ├── footer.jinja2
│ │ │ │ ├── mobile.jinja2
│ │ │ │ └── summary.jinja2
│ │ │ ├── matrix
│ │ │ │ ├── testresult.jinja2
│ │ │ │ ├── metadata.jinja2
│ │ │ │ └── matrix.jinja2
│ │ │ └── shared_session
│ │ │ │ ├── testresult.jinja2
│ │ │ │ ├── metadata.jinja2
│ │ │ │ └── results.jinja2
│ │ │ ├── session_single.jinja2
│ │ │ ├── matrix.jinja2
│ │ │ └── session_with_matrix.jinja2
│ ├── json.py
│ ├── summary.py
│ ├── __init__.py
│ ├── tap.py
│ └── html.py
│ ├── cli
│ ├── commands
│ │ ├── version.py
│ │ ├── list_nodedrivers.py
│ │ ├── list_tests.py
│ │ ├── create_constellation.py
│ │ ├── create_session_template.py
│ │ ├── create_testplan.py
│ │ ├── convert_transcript.py
│ │ ├── info.py
│ │ └── run.py
│ ├── __init__.py
│ └── utils.py
│ ├── nodedrivers
│ ├── saas
│ │ └── __init__.py
│ ├── manual
│ │ └── __init__.py
│ ├── imp
│ │ └── __init__.py
│ ├── sandbox
│ │ └── __init__.py
│ └── wordpress
│ │ └── ubos.py
│ ├── disabled.py
│ ├── tests.py
│ ├── reporting.py
│ └── testruncontroller.py
├── .gitignore
├── .gitattributes
├── tests.smoke
├── imp.node.json
├── mastodon.ubos.node.json
├── wordpress.ubos.node.json
├── mastodon_api.session.json
├── webfinger.session.json
├── mastodon_api_mastodon_api.session.json
├── tests
│ ├── webfinger.py
│ ├── node_with_mastodon_api.py
│ └── nodes_with_mastodon_api_communicate.py
└── README.md
├── README-PyPI.md
├── REUSE.toml
├── tests.unit
├── dummy.py
├── conftest.py
├── test_10_register_nodedrivers.py
├── test_10_create_test_plan_constallation.py
├── test_10_register_tests.py
├── test_20_create_test_plan_session_template.py
├── test_10_registry.py
├── test_50_run_passes.py
├── test_50_run_skip.py
├── test_40_fallback_fediverse_accounts_without_roles_from_testplan.py
├── test_30_create_testplan.py
├── test_40_fallback_fediverse_accounts_with_roles_from_testplan.py
├── test_40_report_node_driver_errors.py
├── test_50_run_not_implemented.py
├── test_50_run_exceptions.py
├── test_40_ubos_mastodon_accounts_from_testplan.py
├── test_50_transcript.py
├── test_40_static_account_manager_fallback_fediverse_accounts.py
├── test_50_run_testplan_sandbox.py
├── test_60_use_manually_entered_application_name.py
└── test_50_run_multistep_assertion_raises.py
├── hatch_build.py
├── README.md
├── .github
└── workflows
│ └── pre-commit.yml
├── LICENSE
├── .pre-commit-config.yaml
├── pyproject.toml
├── RELEASE-HOWTO.md
└── Makefile
/src/feditest/protocols/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | """
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | venv*/
3 | __pycache__/
4 | .vscode/
5 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.ubos-backup filter=lfs diff=lfs merge=lfs -text
2 |
--------------------------------------------------------------------------------
/tests.smoke/imp.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodedriver" : "ImpInProcessNodeDriver"
3 | }
4 |
--------------------------------------------------------------------------------
/tests.smoke/mastodon.ubos.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodedriver": "MastodonUbosNodeDriver"
3 | }
--------------------------------------------------------------------------------
/src/feditest/protocols/fediverse/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Fediverse testing utils
3 | """
4 |
5 | pass
6 |
--------------------------------------------------------------------------------
/tests.smoke/wordpress.ubos.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodedriver": "WordPressPlusPluginsUbosNodeDriver"
3 | }
4 |
--------------------------------------------------------------------------------
/README-PyPI.md:
--------------------------------------------------------------------------------
1 | # FediTest
2 |
3 | Test framework to test distributed, heterogeneous systems...
4 |
5 | ...with complex protocols
6 |
7 | ...such as the Fediverse.
8 |
9 | ---
10 |
11 | More info and documentation: [feditest.org](https://feditest.org/).
12 |
--------------------------------------------------------------------------------
/REUSE.toml:
--------------------------------------------------------------------------------
1 | # Attempting to follow https://reuse.software/spec-3.3/#reusetoml
2 | # If this is wrong or misleading, please point it out
3 |
4 | version = 1
5 |
6 | [[annotations]]
7 | path = "**"
8 | SPDX-FileCopyrightText = "2023 and later, Johannes Ernst https://j12t.org/ and other FediTest contributors"
9 | SPDX-License-Identifier = "MIT"
10 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared/head.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared/footer.jinja2:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests.smoke/mastodon_api.session.json:
--------------------------------------------------------------------------------
1 | {
2 | "constellation": {
3 | "roles": {
4 | "server": null
5 | },
6 | "name": null
7 | },
8 | "tests": [
9 | {
10 | "name": "node_with_mastodon_api::CreateNoteTest",
11 | "rolemapping": null,
12 | "skip": null
13 | }
14 | ],
15 | "name": null
16 | }
17 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared/mobile.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
This FediTest report requires a desktop or laptop-class monitor.
3 |
It cannot be viewed on a mobile device with a small screen.
4 |
Sorry about that. But then, you weren't going to use a phone to fix reported bugs, either, right?
5 |
--------------------------------------------------------------------------------
/tests.smoke/webfinger.session.json:
--------------------------------------------------------------------------------
1 | {
2 | "constellation": {
3 | "roles": {
4 | "client": null,
5 | "server": null
6 | },
7 | "name": null
8 | },
9 | "tests": [
10 | {
11 | "name": "webfinger::fetch",
12 | "rolemapping": null,
13 | "skip": null
14 | }
15 | ],
16 | "name": null
17 | }
18 |
--------------------------------------------------------------------------------
/src/feditest/protocols/fediverse/diag.py:
--------------------------------------------------------------------------------
1 | from feditest.protocols.activitypub.diag import ActivityPubDiagNode
2 | from feditest.protocols.fediverse import FediverseNode
3 | from feditest.protocols.webfinger.diag import WebFingerDiagClient, WebFingerDiagServer
4 |
5 |
6 | class FediverseDiagNode(WebFingerDiagClient, WebFingerDiagServer,ActivityPubDiagNode,FediverseNode):
7 | """
8 | FIXME
9 | """
10 |
--------------------------------------------------------------------------------
/src/feditest/protocols/web/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Types for Nodes that act as HTTP clients and servers.
3 | """
4 |
5 | from feditest.nodedrivers import Node
6 |
7 |
8 | class WebClient(Node):
9 | """
10 | Abstract class used for Nodes that speak HTTP as client.
11 | """
12 | pass
13 |
14 |
15 | class WebServer(Node):
16 | """
17 | Abstract class used for Nodes that speak HTTP as server.
18 | """
19 | pass
20 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/matrix/testresult.jinja2:
--------------------------------------------------------------------------------
1 | {%- if result %}
2 |
3 | {{ result.short_title() | e }}
4 | |
5 | {%- else %}
6 |
7 | Passed
8 | |
9 | {%- endif %}
10 |
--------------------------------------------------------------------------------
/tests.smoke/mastodon_api_mastodon_api.session.json:
--------------------------------------------------------------------------------
1 | {
2 | "constellation": {
3 | "roles": {
4 | "leader_node": null,
5 | "follower_node": null
6 | },
7 | "name": null
8 | },
9 | "tests": [
10 | {
11 | "name": "nodes_with_mastodon_api_communicate::FollowTest",
12 | "rolemapping": null,
13 | "skip": null
14 | }
15 | ],
16 | "name": null
17 | }
18 |
--------------------------------------------------------------------------------
/tests.unit/dummy.py:
--------------------------------------------------------------------------------
1 | #
2 | # Dummy classes for testing
3 | #
4 |
5 | from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver
6 |
7 |
8 | class DummyNode(Node):
9 | pass
10 |
11 |
12 | class DummyNodeDriver(NodeDriver):
13 | # Python 3.12 @Override
14 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> Node:
15 | return DummyNode(rolename, config, account_manager)
16 |
17 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/json.py:
--------------------------------------------------------------------------------
1 | from typing import IO
2 |
3 | from feditest.testruntranscript import TestRunTranscript
4 | from feditest.testruntranscriptserializer import FileOrStdoutTestRunTranscriptSerializer
5 |
6 |
7 | class JsonTestRunTranscriptSerializer(FileOrStdoutTestRunTranscriptSerializer):
8 | """
9 | An object that knows how to serialize a TestRun into JSON format
10 | """
11 | def _write(self, transcript: TestRunTranscript, fd: IO[str]):
12 | transcript.write(fd)
13 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared_session/testresult.jinja2:
--------------------------------------------------------------------------------
1 | {%- if result %}
2 |
3 |
4 |
5 |
{{ result | e }}
6 |
7 | {%- else %}
8 |
11 | {%- endif %}
12 |
--------------------------------------------------------------------------------
/tests.unit/conftest.py:
--------------------------------------------------------------------------------
1 | """
2 | Don't accidentally consider anything below feditest to be a pyunit test.
3 | """
4 |
5 | import inspect
6 |
7 | import pytest
8 |
9 |
10 | @pytest.hookimpl(wrapper=True)
11 | def pytest_pycollect_makeitem(collector, name, obj):
12 | # Ignore all feditest classes and function using
13 | # pytest naming conventions.
14 | if isinstance(obj, type) or inspect.isfunction(obj) or inspect.ismethod(obj):
15 | m = obj.__module__.split(".")
16 | if len(m) > 0 and m[0] == "feditest":
17 | yield
18 | return None
19 | result = yield
20 | return result
21 |
--------------------------------------------------------------------------------
/hatch_build.py:
--------------------------------------------------------------------------------
1 | #
2 | # Set a dynamic version number, see https://hatch.pypa.io/dev/how-to/config/dynamic-metadata/
3 | # At release time, override with env var: FEDITEST_RELEASE_VERSION=y
4 | #
5 |
6 | from datetime import datetime
7 | import os
8 |
9 | from hatchling.metadata.plugin.interface import MetadataHookInterface
10 |
11 |
12 | class JSONMetaDataHook(MetadataHookInterface):
13 | def update(self, metadata):
14 | if 'FEDITEST_RELEASE_VERSION' in os.environ and os.environ['FEDITEST_RELEASE_VERSION'].lower() == 'y':
15 | metadata['version'] = metadata['base_version']
16 | else:
17 | metadata['version'] = metadata['base_version'] + '.dev' + datetime.now().strftime("%Y%m%d%H%M%S")
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `feditest`: test federated protocols such as those in the Fediverse
2 |
3 | This repo contains:
4 |
5 | * the FediTest test framework
6 |
7 | which allows you to define and run test plans that involve constellations of servers (like Fediverse instances) whose communication you want to test.
8 |
9 | The actual tests for the Fediverse are in their own [repository](https://github.com/fediverse-devnet/feditest-tests-fediverse).
10 |
11 | For more details, check out [feditest.org](https://feditest.org/) and find us on Matrix in [#fediverse-testing:matrix.org](https://matrix.to/#/%23fediverse-testing:matrix.org).
12 |
13 | Found a bug? You must be kidding; like in all of Arch Linux, there are no bugs in this software. But if we happen to be wrong, submit a bug report with as much detail as possible right here for this project on Github.
14 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/version.py:
--------------------------------------------------------------------------------
1 | """
2 | Show version
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 |
7 | from feditest.utils import FEDITEST_VERSION
8 |
9 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
10 | """
11 | Run this command.
12 | """
13 | if len(remaining):
14 | parser.print_help()
15 | return 0
16 |
17 | print(FEDITEST_VERSION)
18 | return 0
19 |
20 |
21 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
22 | """
23 | Add command-line options for this sub-command
24 | parent_parser: the parent argparse parser
25 | cmd_name: name of this command
26 | """
27 | parser = parent_parser.add_parser( cmd_name, help='Show feditest version')
28 |
29 | return parser
30 |
--------------------------------------------------------------------------------
/src/feditest/nodedrivers/saas/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | """
3 |
4 | from feditest.nodedrivers import AccountManager, NodeConfiguration
5 | from feditest.nodedrivers.fallback.fediverse import AbstractFallbackFediverseNodeDriver, FallbackFediverseNode
6 |
7 |
8 | class FediverseSaasNodeDriver(AbstractFallbackFediverseNodeDriver):
9 | """
10 | A NodeDriver that supports all protocols but doesn't automate anything and assumes the
11 | Node under test exists as a website that we don't have/can provision/unprovision.
12 | """
13 | # Python 3.12 @override
14 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> FallbackFediverseNode:
15 | return FallbackFediverseNode(rolename, config, account_manager)
16 |
17 |
18 | # No need to override _unprovision_node()
19 |
20 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/summary.py:
--------------------------------------------------------------------------------
1 | from typing import IO
2 |
3 | from feditest.testruntranscript import TestRunTranscript
4 | from feditest.testruntranscriptserializer import FileOrStdoutTestRunTranscriptSerializer
5 |
6 |
7 | class SummaryTestRunTranscriptSerializer(FileOrStdoutTestRunTranscriptSerializer):
8 | """
9 | Knows how to serialize a TestRunTranscript into a single-line summary.
10 | """
11 | def _write(self, transcript: TestRunTranscript, fd: IO[str]):
12 | summary = transcript.build_summary()
13 |
14 | print(f'Test summary: total={ summary.n_total }'
15 | + f', passed={ summary.n_passed }'
16 | + f', failed={ summary.n_failed }'
17 | + f', skipped={ summary.n_skipped }'
18 | + f', errors={ summary.n_errored }.',
19 | file=fd)
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared_session/metadata.jinja2:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/matrix/metadata.jinja2:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/feditest/disabled.py:
--------------------------------------------------------------------------------
1 | # This is a little hack, but useful.
2 | # The problem:
3 | # You have a file that contains some @tests and maybe some @steps inside @tests. You want to temporarily disable them.
4 | # You can go through the file, and change all the @test and @step annotations.
5 | # Or, you can change the import statement from something like:
6 | # from feditest import AssertionFailure, InteropLevel, SpecLevel, step, test
7 | # to
8 | # from feditest.disabled import AssertionFailure, InteropLevel, SpecLevel, step, test
9 | #
10 |
11 | from typing import Callable
12 |
13 | from feditest import AssertionFailure, InteropLevel, SpecLevel, all_node_drivers, all_tests, assert_that, nodedriver # noqa: F401
14 |
15 | def test(to_register: Callable[..., None] | type) -> Callable[..., None] | type:
16 | """
17 | Disabled: do nothing
18 | """
19 | return to_register
20 |
21 |
22 | def step(to_register: Callable[..., None]) -> Callable[..., None]:
23 | """
24 | Disabled: do nothing
25 | """
26 | return to_register
--------------------------------------------------------------------------------
/tests.smoke/tests/webfinger.py:
--------------------------------------------------------------------------------
1 | from feditest import InteropLevel, SpecLevel, assert_that, test
2 | from feditest.protocols.webfinger import WebFingerServer
3 | from feditest.protocols.webfinger.diag import WebFingerDiagClient
4 | from feditest.protocols.webfinger.utils import wf_error
5 | from hamcrest import not_none
6 |
7 |
8 | @test
9 | def fetch(
10 | client: WebFingerDiagClient,
11 | server: WebFingerServer
12 | ) -> None:
13 | """
14 | Perform a normal, simple query on an existing account.
15 | This is not a WebFinger conformance test, it's too lenient for this.
16 | This is a smoke test that tests FediTest can perform these kinds of tests.
17 | """
18 | test_id = server.obtain_account_identifier()
19 |
20 | webfinger_response = client.diag_perform_webfinger_query(test_id)
21 |
22 | assert_that(
23 | webfinger_response.jrd,
24 | not_none(),
25 | wf_error(webfinger_response),
26 | spec_level=SpecLevel.MUST,
27 | interop_level=InteropLevel.PROBLEM)
28 |
--------------------------------------------------------------------------------
/.github/workflows/pre-commit.yml:
--------------------------------------------------------------------------------
1 | name: Pre-commit hooks
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - develop
7 |
8 | jobs:
9 | pre-commit:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v3
15 |
16 | - name: Set up Python
17 | uses: actions/setup-python@v4
18 | with:
19 | python-version: '3.11.9'
20 |
21 | - name: Cache pre-commit environments
22 | id: cache-pre-commit
23 | uses: actions/cache@v3
24 | with:
25 | path: ~/.cache/pre-commit
26 | key: ${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
27 | restore-keys: |
28 | ${{ runner.os }}-pre-commit-
29 |
30 | - name: Install dependencies
31 | run: |
32 | python -m pip install --upgrade pip
33 | pip install pip-tools
34 | pip-compile -o requirements.txt pyproject.toml
35 | pip install --requirement requirements.txt
36 |
37 | - name: Run pre-commit hooks
38 | run: pre-commit run --all-files
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright © 2023 and later, Johannes Ernst https://j12t.org/ and other FediTest contributors
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of
5 | this software and associated documentation files (the “Software”), to deal in
6 | the Software without restriction, including without limitation the rights to
7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8 | the Software, and to permit persons to whom the Software is furnished to do so,
9 | subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/list_nodedrivers.py:
--------------------------------------------------------------------------------
1 | """
2 | List the available drivers for nodes that can be tested
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 |
7 | import feditest
8 | import feditest.cli
9 |
10 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
11 | """
12 | Run this command.
13 | """
14 | if len(remaining):
15 | parser.print_help()
16 | return 0
17 |
18 | if args.nodedriversdir:
19 | feditest.load_node_drivers_from(args.nodedriversdir)
20 | feditest.load_default_node_drivers()
21 |
22 | for name in sorted(feditest.all_node_drivers.keys()):
23 | print(name)
24 |
25 | return 0
26 |
27 |
28 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
29 | """
30 | Add command-line options for this sub-command
31 | parent_parser: the parent argparse parser
32 | cmd_name: name of this command
33 | """
34 | parser = parent_parser.add_parser(cmd_name, help='List the available drivers for nodes that can be tested')
35 | parser.add_argument('--nodedriversdir', action='append', help='Directory or directories where to find extra drivers for nodes that can be tested')
36 |
37 | return parser
38 |
--------------------------------------------------------------------------------
/src/feditest/nodedrivers/manual/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | A NodeDriver that supports all protocols but doesn't automate anything.
3 | """
4 |
5 | from feditest.nodedrivers import AccountManager, Node, NodeConfiguration
6 | from feditest.nodedrivers.fallback.fediverse import AbstractFallbackFediverseNodeDriver, FallbackFediverseNode
7 | from feditest.protocols.fediverse import FediverseNode
8 | from feditest.utils import prompt_user
9 |
10 |
11 | class FediverseManualNodeDriver(AbstractFallbackFediverseNodeDriver):
12 | """
13 | A NodeDriver that supports all web server-side protocols but doesn't automate anything.
14 | """
15 | # Python 3.12 @override
16 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> FediverseNode:
17 | prompt_user(
18 | f'Manually provision the Node for constellation role { rolename }'
19 | + f' at host { config.hostname } with app { config.app } and hit return when done: ')
20 | return FallbackFediverseNode(rolename, config, account_manager)
21 |
22 |
23 | # Python 3.12 @override
24 | def _unprovision_node(self, node: Node) -> None:
25 | prompt_user(f'Manually unprovision the Node for constellation role { node.rolename } and hit return when done: ')
26 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/session_single.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% include "partials/shared/head.jinja2" %}
5 | {{ transcript.plan.name | e }} | FediTest
6 |
7 |
8 |
9 | FediTest Report: {{ transcript.plan.name | e }}
10 | {{ transcript.id }}
11 |
12 | {% include "partials/shared/mobile.jinja2" %}
13 |
20 |
21 |
Test Run Summary
22 | {% include "partials/shared/summary.jinja2" %}
23 |
24 |
25 | {% include "partials/shared_session/results.jinja2" %}
26 |
27 |
28 |
Test Run Metadata
29 | {% include "partials/shared_session/metadata.jinja2" %}
30 |
31 | {% include "partials/shared/footer.jinja2" %}
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/list_tests.py:
--------------------------------------------------------------------------------
1 | """
2 | List the available tests
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 | import re
7 |
8 | import feditest
9 |
10 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
11 | """
12 | Run this command.
13 | """
14 | if len(remaining):
15 | parser.print_help()
16 | return 0
17 |
18 | pattern = re.compile(args.filter_regex) if args.filter_regex else None
19 |
20 | feditest.load_default_tests()
21 | feditest.load_tests_from(args.testsdir)
22 |
23 | for name in sorted(feditest.all_tests.keys()):
24 | if pattern is None or pattern.match(name):
25 | print(name)
26 |
27 | return 0
28 |
29 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
30 | """
31 | Add command-line options for this sub-command
32 | parent_parser: the parent argparse parser
33 | cmd_name: name of this command
34 | """
35 | parser = parent_parser.add_parser(cmd_name, help='List the available tests')
36 | parser.add_argument('--filter-regex', default=None, help='Only list tests whose name matches this regular expression')
37 | parser.add_argument('--testsdir', action='append', help='Directory or directories where to find tests')
38 |
39 | return parser
40 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/matrix.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% include "partials/shared/head.jinja2" %}
5 | {{ transcript | e }} ({{ transcript.id }}) | FediTest
6 |
7 |
8 |
9 | FediTest: {{ transcript | e }}
10 | {{ transcript.id }}
11 |
12 | {% include "partials/shared/mobile.jinja2" %}
13 |
20 |
21 |
Test Run Summary
22 | {% include "partials/shared/summary.jinja2" %}
23 |
24 |
25 | {% with session_links=true %}
26 |
Test Results
27 | {% include "partials/matrix/matrix.jinja2" %}
28 | {% endwith %}
29 |
30 |
31 |
Test Run Metadata
32 | {% include "partials/matrix/metadata.jinja2" %}
33 |
34 | {% include "partials/shared/footer.jinja2" %}
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/session_with_matrix.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% include "partials/shared/head.jinja2" %}
5 | {{ run_session | e }} ({{ transcript.id }}) | FediTest
6 |
7 |
8 |
9 | FediTest: {{ run_session | e }}
10 | {{ transcript.id }} ⇧
11 |
12 | {% include "partials/shared/mobile.jinja2" %}
13 |
20 |
21 |
Test Run Session Summary
22 | {% include "partials/shared/summary.jinja2" %}
23 |
24 |
25 | {% include "partials/shared_session/results.jinja2" %}
26 |
27 |
28 |
Test Run Session Metadata
29 | {% include "partials/shared_session/metadata.jinja2" %}
30 |
31 | {% include "partials/shared/footer.jinja2" %}
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/feditest/protocols/activitypub/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | ActivityPub testing utils
3 | """
4 |
5 | from typing import Any
6 |
7 | from hamcrest.core.base_matcher import BaseMatcher
8 | from hamcrest.core.description import Description
9 |
10 | from feditest.nodedrivers import Node
11 | from feditest.utils import boolean_response_parse_validate, prompt_user_parse_validate
12 |
13 |
14 | class MemberOfCollectionMatcher(BaseMatcher[Any]):
15 | """
16 | Custom matcher: decide whether a URI is a member of Collection identified by another URI
17 | """
18 | def __init__(self, collection_uri: str, node: Node):
19 | """
20 | collection_uri: the URI identifying the collection which to examine
21 | """
22 | self._collection_uri = collection_uri
23 | self._node = node
24 |
25 |
26 | def _matches(self, member_candidate_uri: str) -> bool:
27 | ret = prompt_user_parse_validate(
28 | f'Is "{ member_candidate_uri }" a member of the collection at URI "{ self._collection_uri }"? ',
29 | parse_validate=boolean_response_parse_validate)
30 | return ret
31 |
32 |
33 | def describe_to(self, description: Description) -> None:
34 | description.append_text('Not a member of the set')
35 |
36 |
37 | def is_member_of_collection_at(arg: str, node: Node) -> MemberOfCollectionMatcher :
38 | return MemberOfCollectionMatcher(arg, node)
39 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/__init__.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | import io
3 | import sys
4 | from typing import IO
5 |
6 | from feditest.testruntranscript import TestRunTranscript
7 |
8 |
9 | class TestRunTranscriptSerializer(ABC):
10 | """
11 | An object that knows how to serialize a TestRunTranscript into some output format.
12 | """
13 | @abstractmethod
14 | def write(self, transcript: TestRunTranscript, dest: str | None):
15 | ...
16 |
17 |
18 | class FileOrStdoutTestRunTranscriptSerializer(TestRunTranscriptSerializer):
19 | def write(self, transcript: TestRunTranscript, dest: str | None) -> None:
20 | """
21 | dest: name of the file to write to, or stdout
22 | """
23 | if dest and isinstance(dest,str):
24 | with open(dest, "w", encoding="utf8") as out:
25 | self._write(transcript, out)
26 | else:
27 | self._write(transcript, sys.stdout)
28 |
29 |
30 | def write_to_string(self, transcript: TestRunTranscript) -> str:
31 | """
32 | Return the written content as a string; this is for testing.
33 | """
34 | string_io = io.StringIO()
35 | self._write(transcript, string_io)
36 | return string_io.getvalue()
37 |
38 |
39 | @abstractmethod
40 | def _write(self, transcript: TestRunTranscript, fd: IO[str]) -> None:
41 | ...
42 |
43 |
44 |
--------------------------------------------------------------------------------
/tests.unit/test_10_register_nodedrivers.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that @nodedriver annotations register NodeDrivers correctly.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import nodedriver
9 | from feditest.nodedrivers import NodeDriver
10 |
11 |
12 | @pytest.fixture(scope="module", autouse=True)
13 | def init():
14 | """ Keep these isolated to this module """
15 | feditest.all_node_drivers = {}
16 | feditest._loading_node_drivers = True
17 |
18 | @nodedriver
19 | class NodeDriver1(NodeDriver):
20 | pass
21 |
22 | @nodedriver
23 | class NodeDriver2(NodeDriver):
24 | pass
25 |
26 | @nodedriver
27 | class NodeDriver3(NodeDriver):
28 | pass
29 |
30 | feditest._loading_node_drivers = False
31 |
32 |
33 | def test_node_drivers_registered() -> None:
34 | assert len(feditest.all_node_drivers) == 3
35 |
36 | prefix = 'test_10_register_nodedrivers.init..'
37 | assert prefix + 'NodeDriver1' in feditest.all_node_drivers
38 | assert prefix + 'NodeDriver2' in feditest.all_node_drivers
39 | assert prefix + 'NodeDriver3' in feditest.all_node_drivers
40 |
41 | # Can't directly refer to NodeDriverX for some reason
42 | assert feditest.all_node_drivers.get(prefix + 'NodeDriver1').__name__.endswith('NodeDriver1')
43 | assert feditest.all_node_drivers.get(prefix + 'NodeDriver2').__name__.endswith('NodeDriver2')
44 | assert feditest.all_node_drivers.get(prefix + 'NodeDriver3').__name__.endswith('NodeDriver3')
45 |
--------------------------------------------------------------------------------
/tests.unit/test_10_create_test_plan_constallation.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the equivalent of `feditest create-constellation`
3 | """
4 |
5 | import pytest
6 |
7 | from feditest.testplan import TestPlanConstellation, TestPlanConstellationNode
8 |
9 | @pytest.fixture(scope="session")
10 | def node1() -> TestPlanConstellationNode:
11 | return TestPlanConstellationNode( 'node1-driver', { 'foo' : 'Foo', 'bar' : 'Bar'})
12 |
13 |
14 | @pytest.fixture(scope="session")
15 | def node2() -> TestPlanConstellationNode:
16 | return TestPlanConstellationNode( 'node2-driver', { 'baz' : 'Baz'})
17 |
18 |
19 | def test_unnamed(
20 | node1: TestPlanConstellationNode,
21 | node2: TestPlanConstellationNode
22 | ) -> None:
23 | """
24 | TestPlanConstellations don't have automatic names.
25 | """
26 | roles = {
27 | 'role1' : node1,
28 | 'role2' : node2
29 | }
30 |
31 | constellation = TestPlanConstellation(roles)
32 |
33 | assert len(constellation.roles) == 2
34 | assert constellation.name is None
35 |
36 |
37 | def test_named(
38 | node1: TestPlanConstellationNode,
39 | node2: TestPlanConstellationNode
40 | ) -> None:
41 | """
42 | TestPlanConstellations can be named
43 | """
44 |
45 | NAME = 'My constellation'
46 |
47 | roles = {
48 | 'role1' : node1,
49 | 'role2' : node2
50 | }
51 |
52 | constellation = TestPlanConstellation(roles)
53 | constellation.name = NAME
54 |
55 | assert len(constellation.roles) == 2
56 | assert constellation.name == NAME
57 |
58 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/create_constellation.py:
--------------------------------------------------------------------------------
1 | """
2 | Combine node definitions into a constellation.
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 |
7 | from feditest.cli.utils import create_constellation_from_nodes
8 | from feditest.testplan import TestPlanConstellation, TestPlanConstellationNode
9 |
10 |
11 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
12 | """
13 | Run this command.
14 | """
15 |
16 | roles : dict[str,TestPlanConstellationNode | None] = {}
17 |
18 | if remaining:
19 | parser.print_help()
20 | return 0
21 |
22 | constellation = TestPlanConstellation(roles)
23 |
24 | constellation = create_constellation_from_nodes(args)
25 | if args.name:
26 | constellation.name = args.name
27 |
28 | if args.out:
29 | constellation.save(args.out)
30 | else:
31 | constellation.print()
32 |
33 | return 0
34 |
35 |
36 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
37 | """
38 | Add command-line options for this sub-command
39 | parent_parser: the parent argparse parser
40 | cmd_name: name of this command
41 | """
42 | parser = parent_parser.add_parser(cmd_name, help='Combine node definitions into a constellation')
43 | parser.add_argument('--name', default=None, required=False, help='Name of the generated constellation')
44 | parser.add_argument('--node', action='append', required=True,
45 | help="Use = to specify that the node definition in 'file' is supposed to be used for constellation role 'role'")
46 | parser.add_argument('--out', '-o', default=None, required=False, help='Name of the file for the generated constellation')
47 |
48 | return parser
49 |
--------------------------------------------------------------------------------
/src/feditest/protocols/sandbox/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Abstractions for the toy "Sandbox" protocol.
3 | """
4 |
5 | from datetime import datetime, UTC
6 | from typing import List
7 |
8 | from feditest.nodedrivers import Node, NotImplementedByNodeError
9 |
10 | class SandboxLogEvent:
11 | """
12 | The structure of the data inserted into the log.
13 | """
14 | def __init__(self, a: float, b: float, c: float):
15 | self.when = datetime.now(UTC)
16 | self.a = a
17 | self.b = b
18 | self.c = c
19 |
20 |
21 | class SandboxMultServer(Node):
22 | """
23 | This is a "Server" Node in a to-be-tested toy protocol. It is only useful to illustrate how FediTest works.
24 | """
25 | def mult(self, a: float, b: float) -> float:
26 | """
27 | The operation that's being tested
28 | """
29 | raise NotImplementedByNodeError(self, SandboxMultServer.mult)
30 |
31 |
32 | def start_logging(self):
33 | """
34 | Activate logging of mult() operations
35 | """
36 | raise NotImplementedByNodeError(self, SandboxMultServer.start_logging)
37 |
38 |
39 | def get_and_clear_log(self) -> List[SandboxLogEvent]:
40 | """
41 | Stop logging of mult() operations, return what has been logged so far
42 | and clear the log
43 | """
44 | raise NotImplementedByNodeError(self, SandboxMultServer.get_and_clear_log)
45 |
46 |
47 | class SandboxMultClient(Node):
48 | """
49 | This is a "Client" Node in a to-be-tested toy protocol. It is only useful to illustrate how FediTest works.
50 | """
51 | def cause_mult(self, server: SandboxMultServer, a: float, b: float) -> float:
52 | """
53 | Enable FediTest to make the client perform the mult() operation on the server.
54 | """
55 | raise NotImplementedByNodeError(self, SandboxMultClient.cause_mult)
56 |
--------------------------------------------------------------------------------
/tests.unit/test_10_register_tests.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that @test and @step annotations register their functions/classes/methods correctly.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import step, test
9 | from feditest.tests import TestFromTestClass, TestFromTestFunction
10 |
11 |
12 | @pytest.fixture(scope="module", autouse=True)
13 | def init():
14 | """ Keep these isolated to this module """
15 | feditest.all_tests = {}
16 | feditest._registered_as_test = {}
17 | feditest._registered_as_test_step = {}
18 | feditest._loading_tests = True
19 |
20 | @test
21 | def test1() -> None:
22 | return
23 |
24 | @test
25 | def test2() -> None:
26 | return
27 |
28 | @test
29 | def test3() -> None:
30 | return
31 |
32 | @test
33 | class TestA():
34 | @step
35 | def testa1(self) -> None:
36 | return
37 |
38 | @step
39 | def testa2(self) -> None:
40 | return
41 |
42 | feditest._loading_tests = False
43 | feditest._load_tests_pass2()
44 |
45 |
46 | def test_tests_registered() -> None:
47 | assert len(feditest.all_tests) == 4
48 |
49 |
50 | def test_functions() -> None:
51 | functions = [ testInstance for testInstance in feditest.all_tests.values() if isinstance(testInstance, TestFromTestFunction) ]
52 | assert len(functions) == 3
53 | functions[0].name.endswith('test1')
54 | functions[1].name.endswith('test2')
55 | functions[2].name.endswith('test3')
56 |
57 |
58 | def test_classes() -> None:
59 | classes = [ testInstance for testInstance in feditest.all_tests.values() if isinstance(testInstance, TestFromTestClass) ]
60 | assert len(classes) == 1
61 |
62 | singleClass = classes[0]
63 | assert singleClass.name.endswith('TestA')
64 |
65 | assert len(singleClass.steps) == 2
66 | singleClass.steps[0].name.startswith('testa')
67 | singleClass.steps[1].name.startswith('testa')
68 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/create_session_template.py:
--------------------------------------------------------------------------------
1 | """
2 | Create a template for a test session. This is very similar to list-tests, but the output is to be used
3 | as input for generate-testplan.
4 | """
5 |
6 | from argparse import ArgumentParser, Namespace, _SubParsersAction
7 |
8 | import feditest
9 | from feditest.cli.utils import create_session_template_from_tests
10 |
11 |
12 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
13 | """
14 | Run this command.
15 | """
16 | if len(remaining):
17 | parser.print_help()
18 | return 0
19 |
20 | feditest.load_default_tests()
21 | feditest.load_tests_from(args.testsdir)
22 |
23 | session_template = create_session_template_from_tests(args)
24 |
25 | if args.out:
26 | session_template.save(args.out)
27 | else:
28 | session_template.print()
29 |
30 | return 0
31 |
32 |
33 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
34 | """
35 | Add command-line options for this sub-command
36 | parent_parser: the parent argparse parser
37 | cmd_name: name of this command
38 | """
39 | # general flags and options
40 | parser = parent_parser.add_parser(cmd_name, help='Create a template for a test session')
41 | parser.add_argument('--testsdir', action='append', help='Directory or directories where to find tests')
42 |
43 | # session template options
44 | parser.add_argument('--name', default=None, required=False, help='Name of the created test session template')
45 | parser.add_argument('--filter-regex', default=None, help='Only include tests whose name matches this regular expression')
46 | parser.add_argument('--test', action='append', help='Include this/these named tests(s)')
47 |
48 | # output options
49 | parser.add_argument('--out', '-o', default=None, required=False, help='Name of the file for the created test session template')
50 |
51 | return parser
52 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/matrix/matrix.jinja2:
--------------------------------------------------------------------------------
1 | {% macro column_headings(first) %}
2 |
3 | | {{ first | e }} |
4 | {%- for run_session in transcript.sessions %}
5 | {%- set constellation = run_session.constellation %}
6 |
7 |
8 | {{ run_session }}
9 |
10 | {%- for role, node in constellation.nodes.items() %}
11 | - {{ role | e }}
12 | - {{ node.appdata['app'] | e }}
13 | {%- endfor %}
14 |
15 |
16 | |
17 | {%- endfor %}
18 |
19 | {% endmacro %}
20 |
21 |
22 |
23 |
24 |
25 | {%- for run_session in transcript.sessions %}
26 |
27 | {%- endfor %}
28 |
29 |
30 | {{ column_headings("{0} tests in {1} sessions (alphabetical order)".format(len(transcript.test_meta), len(transcript.sessions))) }}
31 |
32 |
33 | {%- for test_index, ( _, test_meta ) in enumerate(sorted(transcript.test_meta.items())) %}
34 |
35 | |
36 | {{ permit_line_breaks_in_identifier(test_meta.name) | safe }}
37 | {%- if test_meta.description %}
38 | {{ test_meta.description | e }}
39 | {%- endif %}
40 | |
41 | {%- for session_index, run_session in enumerate(transcript.sessions) %}
42 | {%- for result in get_results_for(transcript, run_session, test_meta) %}
43 | {% include "partials/matrix/testresult.jinja2" %}
44 | {%- endfor %}
45 | {%- endfor %}
46 |
47 | {%- endfor %}
48 |
49 |
50 | {{ column_headings("") }}
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_install_hook_types:
2 | - pre-push
3 |
4 | default_language_version:
5 | python: python3.11
6 |
7 | repos:
8 | - repo: https://github.com/pre-commit/pre-commit-hooks
9 | rev: v4.6.0
10 | hooks:
11 | # - id: trailing-whitespace
12 | # args: [--markdown-linebreak-ext=md]
13 | # - id: end-of-file-fixer
14 | - id: mixed-line-ending
15 | - id: check-yaml
16 | - id: sort-simple-yaml
17 | - id: check-added-large-files
18 |
19 | # - repo: https://github.com/psf/black
20 | # rev: 24.8.0
21 | # hooks:
22 | # - id: black
23 | # args:
24 | # - --config=pyproject.toml
25 |
26 | # - repo: https://github.com/pycqa/isort
27 | # rev: 5.13.2
28 | # hooks:
29 | # - id: isort
30 | # name: isort (python)
31 | # args: ["--profile", "black", "--filter-files"]
32 |
33 | - repo: https://github.com/astral-sh/ruff-pre-commit
34 | # Ruff version.
35 | rev: v0.6.0
36 | hooks:
37 | # Run the linter.
38 | - id: ruff
39 | args: ["--fix"]
40 | # Run the formatter.
41 | # - id: ruff-format
42 |
43 | - repo: local
44 | hooks:
45 | - id: pytest
46 | name: pytest
47 | entry: pytest
48 | language: system
49 | #language: script
50 | pass_filenames: false
51 | # alternatively you could `types: [python]` so it only runs when python files change
52 | # though tests might be invalidated if you were to say change a data file
53 | always_run: true
54 |
55 | - repo: https://github.com/pre-commit/mirrors-mypy
56 | rev: v1.11.2
57 | hooks:
58 | - id: mypy
59 | files: src
60 | args:
61 | - "--ignore-missing-imports" # Not managing to get it to work from pre-commit without this flag
62 | - "--namespace-packages"
63 | - "--explicit-package-bases"
64 | - "--install-types"
65 | - "--non-interactive"
66 |
67 | - repo: local
68 | hooks:
69 | - id: nocommit
70 | name: NOCOMMIT check
71 | entry: NOCOMMIT
72 | language: pygrep
73 | exclude: .pre-commit-config.yaml
74 | types: [text]
75 |
--------------------------------------------------------------------------------
/tests.unit/test_20_create_test_plan_session_template.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the equivalent of `feditest create-session-template`
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import test
9 | from feditest.nodedrivers import Node
10 | from feditest.testplan import TestPlanSessionTemplate, TestPlanTestSpec
11 |
12 |
13 | @pytest.fixture(scope="module", autouse=True)
14 | def init():
15 | """ Keep these isolated to this module """
16 | feditest.all_tests = {}
17 | feditest._registered_as_test = {}
18 | feditest._registered_as_test_step = {}
19 | feditest._loading_tests = True
20 |
21 | @test
22 | def test1(role_a: Node) -> None:
23 | return
24 |
25 | @test
26 | def test2(role_a: Node, role_c: Node) -> None:
27 | return
28 |
29 | @test
30 | def test3(role_b: Node) -> None:
31 | return
32 |
33 | feditest._loading_tests = False
34 | feditest._load_tests_pass2()
35 |
36 |
37 | SESSION_TEMPLATE_NAME = 'My Session'
38 |
39 |
40 | def _session_template(session_name: str | None) -> TestPlanSessionTemplate:
41 | test_plan_specs : list[TestPlanTestSpec]= []
42 | for name in sorted(feditest.all_tests.keys()):
43 | test = feditest.all_tests.get(name)
44 | if test is None: # make linter happy
45 | continue
46 | test_plan_spec = TestPlanTestSpec(name)
47 | test_plan_specs.append(test_plan_spec)
48 |
49 | session = TestPlanSessionTemplate(test_plan_specs, session_name)
50 | return session
51 |
52 |
53 | @pytest.fixture()
54 | def unnamed() -> TestPlanSessionTemplate:
55 | return _session_template(None)
56 |
57 |
58 | @pytest.fixture()
59 | def named() -> TestPlanSessionTemplate:
60 | return _session_template(SESSION_TEMPLATE_NAME)
61 |
62 |
63 | def test_session_template_unnamed(unnamed: TestPlanSessionTemplate) -> None:
64 | assert unnamed.name is None
65 | assert str(unnamed) == 'Unnamed'
66 | assert len(unnamed.tests) == 3
67 |
68 |
69 | def test_session_template_named(named: TestPlanSessionTemplate) -> None:
70 | assert named.name == SESSION_TEMPLATE_NAME
71 | assert str(named) == SESSION_TEMPLATE_NAME
72 | assert len(named.tests) == 3
73 |
--------------------------------------------------------------------------------
/tests.smoke/tests/node_with_mastodon_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for our implementation of the NodeWithMastodonAPI.
3 | This tests produces regular Python assertion errors, not feditest assertion errors, because
4 | problems here are problems in our NodeDriver, not Fediverse interop problems.
5 | """
6 |
7 | from datetime import datetime
8 |
9 | from feditest import poll_until, step, test
10 | from feditest.nodedrivers.mastodon import NodeWithMastodonAPI
11 |
12 | # @test
13 | # def app_version(
14 | # server: NodeWithMastodonAPI
15 | # ) -> None:
16 | # # FIXME: need to implement property mastodon_api_app_version
17 | # # This should access Mastodon without an authenticated user and we don't currently have code for how to do that
18 | # assert re.fullmatch(r'\d+\.\d+\.\d+', server.mastodon_api_app_version), "Invalid version"
19 |
20 |
21 | @test
22 | class CreateNoteTest:
23 | """
24 | Tests that we can create a Note through the Mastodon API.
25 | """
26 | def __init__(self,
27 | server: NodeWithMastodonAPI
28 | ) -> None:
29 | self.server = server
30 | self.actor_acct_uri = None
31 | self.note_uri = None
32 |
33 |
34 | @step
35 | def provision_actor(self):
36 | self.actor_acct_uri = self.server.obtain_actor_acct_uri()
37 | assert self.actor_acct_uri
38 |
39 |
40 | # @step
41 | # def start_reset_all(self):
42 | # self._reset_all()
43 |
44 |
45 | @step
46 | def create_note(self):
47 | self.note_uri = self.server.make_create_note(self.actor_acct_uri, f"testing make_create_note {datetime.now()}")
48 | assert self.note_uri
49 |
50 |
51 | @step
52 | def wait_for_note_in_inbox(self):
53 | poll_until(lambda: self.server.actor_has_received_object(self.actor_acct_uri, self.note_uri))
54 |
55 |
56 | # @step
57 | # def end_reset_all(self):
58 | # self._reset_all()
59 |
60 |
61 | def _reset_all(self):
62 | """
63 | Clean up data. This is here so the test is usable with non-brand-new instances.
64 | """
65 | self.server.delete_all_followers_of(self.actor_acct_uri)
66 | self.server.delete_all_statuses_by(self.actor_acct_uri)
67 |
68 |
--------------------------------------------------------------------------------
/tests.smoke/README.md:
--------------------------------------------------------------------------------
1 | # FediTest smoke tests
2 |
3 | ## Files
4 |
5 | The files in this directory:
6 |
7 | ### Session Templates
8 |
9 | `mastodon_api.session.json`:
10 | : Tests our use of the Mastodon API against a single Node that implements `NodeWithMastodonAPI`, such as Mastodon or WordPress with plugins
11 |
12 | `mastodon_api_mastodon_api.session.json`:
13 | : Simple tests that test whether two `NodeWithMastodonAPI`s can communicate.
14 |
15 | ### Constellations
16 |
17 | `mastodon_mastodon.ubos.constellation.json`:
18 | : A Constellation consisting of two instances of Mastodon, running on UBOS
19 |
20 | `mastodon.saas.constellation.json`:
21 | : A (degenerate) Constellation consisting of only one instance of Mastodon that already runs at a public hostname
22 |
23 | `mastodon.ubos.constellation.json`:
24 | : A (degenerate) Constellation consisting of only one instance of Mastodon, running on UBOS
25 |
26 | `wordpress_mastodon.ubos.constellation.json`:
27 | : A Constellation consisting of one instance of Mastodon and one of WordPress with plugins, running on UBOS
28 |
29 | `wordpress.saas.constellation.json`:
30 | : A (degenerate) Constellation consisting of only one instance of WordPress with plugins that already runs at a public hostname
31 |
32 | `wordpress.ubos.constellation.json`:
33 | : A (degenerate) Constellation consisting of only one instance of WordPress with plugins, running on UBOS
34 |
35 | ### Actual Tests
36 |
37 | `tests/node_with_mastodon_api.py`:
38 | : Tests the Mastodon API
39 |
40 | `tests/nodes_with_mastodon_api_communicate.py`:
41 | : Tests that two nodes with the Mastodon API can communicate
42 |
43 | ## How to run
44 |
45 | Combine a session template with a constellation, such as:
46 |
47 | `feditest run --session mastodon_api.session.json --constellation mastodon.ubos.constellation.json`
48 | : Runs the Mastodon API test against a single Mastodon node on UBOS
49 |
50 | `feditest run --session mastodon_api.session.json --constellation wordpress.ubos.constellation.json`
51 | : Runs the Mastodon API test against a single WordPress plus plugins node on UBOS
52 |
53 | etc.
54 |
55 | If you invoke FediTest from any directory other than this one, make sure you specify a `--testsdir ` to this directory's
56 | subdirectory `tests` so FediTest can find the test files.
57 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 |
6 | [project]
7 | name = "feditest"
8 | base_version = "0.6"
9 | dynamic = ["version"]
10 | authors = [
11 | { name="Johannes Ernst", email="git@j12t.org" },
12 | { name="Steve Bate", email="svc-github@stevebate.net" }
13 | ]
14 | maintainers = [
15 | { name="Johannes Ernst", email="git@j12t.org" },
16 | { name="Steve Bate", email="svc-github@stevebate.net" }
17 | ]
18 | dependencies = [
19 | "cryptography",
20 | "httpx",
21 | "langcodes",
22 | "msgspec",
23 | "multidict",
24 | "jinja2",
25 | "pyhamcrest",
26 | "requests",
27 | "types-requests",
28 | "pre-commit",
29 |
30 | # For testing: not sure how to specify this just for testing
31 | "pytest",
32 | "beautifulsoup4"
33 | ]
34 |
35 | description = "Test framework to test distributed, heterogeneous systems with complex protocols such as the Fediverse"
36 | readme = "README-PyPI.md"
37 |
38 | # We develop on 3.11, so we can support debian 12 (including Raspberry PI OS) systems,
39 | # which have not been upgraded to 3.12 yet.
40 | requires-python = ">=3.11"
41 | # We really want 3.12 so we can use @override
42 | # Do not specify an upper boundary, see https://github.com/fediverse-devnet/feditest/issues/412
43 |
44 | classifiers = [
45 | "Development Status :: 3 - Alpha",
46 | "Environment :: Console",
47 | "Intended Audience :: Developers",
48 | "License :: OSI Approved :: MIT License",
49 | "Operating System :: MacOS :: MacOS X",
50 | "Operating System :: POSIX :: Linux",
51 | "Programming Language :: Python :: 3",
52 | "Topic :: Software Development :: Testing"
53 | ]
54 |
55 | [project.scripts]
56 | feditest = "feditest.cli:main"
57 |
58 | [project.urls]
59 | Homepage = "https://feditest.org/"
60 |
61 | [tool.hatch.build.targets.sdist]
62 | exclude = [
63 | "docs/"
64 | ]
65 |
66 | [tool.hatch.metadata.hooks.custom]
67 | # Empty: https://hatch.pypa.io/dev/how-to/config/dynamic-metadata/
68 |
69 | [tool.pylint."MESSAGES CONTROL"]
70 | max-line-length=120
71 | disable="arguments-renamed, empty-docstring, global-variable-not-assigned, line-too-long, missing-class-docstring, missing-function-docstring, too-few-public-methods, too-many-arguments"
72 |
73 | [tool.pytest.ini_options]
74 | pythonpath = ["src"]
75 | testpaths = [ "tests.unit" ]
76 |
77 | [tool.ruff]
78 | target-version = "py311"
79 |
--------------------------------------------------------------------------------
/src/feditest/cli/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Main entry point for CLI invocation
3 | """
4 |
5 | from argparse import ArgumentError, ArgumentParser, Action
6 | import importlib
7 | import sys
8 | import traceback
9 | from types import ModuleType
10 |
11 | from feditest.reporting import fatal, set_reporting_level, warning
12 | from feditest.utils import find_submodules
13 | import feditest.cli.commands
14 |
15 | def main() -> None:
16 | """
17 | Main entry point for CLI invocation.
18 | """
19 |
20 | # Discover and install sub-commands
21 | cmds = find_commands()
22 |
23 | parser = ArgumentParser(description='FediTest: test federated protocols')
24 | parser.add_argument('-v', '--verbose', action='count', default=0,
25 | help='Display extra output. May be repeated for even more output' )
26 | cmd_parsers : Action = parser.add_subparsers(dest='command', required=True)
27 | cmd_sub_parsers : dict[str,ArgumentParser] = {}
28 |
29 | for cmd_name, cmd in cmds.items():
30 | cmd_sub_parsers[cmd_name] = cmd.add_sub_parser(cmd_parsers, cmd_name)
31 |
32 | args,remaining = parser.parse_known_args(sys.argv[1:])
33 | cmd_name = args.command
34 |
35 | set_reporting_level(args.verbose)
36 |
37 | if sys.version_info.major != 3 or sys.version_info.minor != 11:
38 | warning(f"feditest currently requires Python 3.11. You are using { sys.version }"
39 | + " and may get unpredictable results. We'll get to other versions in the future.")
40 |
41 | if cmd_name in cmds:
42 | try :
43 | ret = cmds[cmd_name].run(cmd_sub_parsers[cmd_name], args, remaining)
44 | sys.exit( ret )
45 |
46 | except ArgumentError as e:
47 | fatal(e.message)
48 | except Exception as e: # pylint: disable=broad-exception-caught
49 | if args.verbose > 1:
50 | traceback.print_exception( e )
51 | fatal( str(type(e)), '--', e )
52 |
53 | else:
54 | fatal('Sub-command not found:', cmd_name, '. Add --help for help.' )
55 |
56 |
57 | def find_commands() -> dict[str,ModuleType]:
58 | """
59 | Find available commands.
60 | """
61 | cmd_names = find_submodules(feditest.cli.commands)
62 |
63 | cmds = {}
64 | for cmd_name in cmd_names:
65 | mod = importlib.import_module('feditest.cli.commands.' + cmd_name)
66 | cmds[cmd_name.replace('_', '-')] = mod
67 |
68 | return cmds
69 |
70 |
71 | if __name__ == '__main__':
72 | main()
73 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/create_testplan.py:
--------------------------------------------------------------------------------
1 | """
2 | Create a test plan.
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 | import feditest
7 | from feditest.cli.utils import create_plan_from_session_and_constellations
8 | from feditest.reporting import fatal
9 |
10 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
11 | """
12 | Run this command.
13 | """
14 | if len(remaining):
15 | parser.print_help()
16 | return 0
17 |
18 | feditest.load_default_tests()
19 | feditest.load_tests_from(args.testsdir)
20 |
21 | test_plan = create_plan_from_session_and_constellations(args)
22 | if test_plan:
23 | if args.out:
24 | test_plan.save(args.out)
25 | else:
26 | test_plan.print()
27 | else:
28 | fatal('Failed to create test plan from the provided arguments')
29 |
30 | return 0
31 |
32 |
33 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
34 | """
35 | Add command-line options for this sub-command
36 | parent_parser: the parent argparse parser
37 | cmd_name: name of this command
38 | """
39 | # general flags and options
40 | parser = parent_parser.add_parser(cmd_name, help='Create a test plan by running all provided test sessions in all provided constellations')
41 | parser.add_argument('--testsdir', action='append', help='Directory or directories where to find tests')
42 |
43 | # test plan options
44 | parser.add_argument('--name', default=None, required=False, help='Name of the generated test plan')
45 | parser.add_argument('--constellation', action='append', help='File(s) each containing a JSON fragment defining a constellation')
46 | parser.add_argument('--session', '--session-template', required=False, help='File containing a JSON fragment defining a test session')
47 | parser.add_argument('--node', action='append',
48 | help="Use = to specify that the node definition in 'file' is supposed to be used for constellation role 'role'")
49 | parser.add_argument('--filter-regex', default=None, help='Only include tests whose name matches this regular expression')
50 | parser.add_argument('--test', action='append', help='Run this/these named tests(s)')
51 |
52 | # output options
53 | parser.add_argument('--out', '-o', default=None, required=False, help='Name of the file for the generated test plan')
54 |
55 | return parser
56 |
--------------------------------------------------------------------------------
/tests.unit/test_10_registry.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the UBOS host registry / CA
3 | """
4 |
5 | import tempfile
6 |
7 | from feditest.registry import Registry
8 |
9 |
10 | def test_allocates_domain():
11 | r = Registry.create()
12 | assert len(r.ca.domain) > 4
13 |
14 |
15 | def test_uses_domain():
16 | D = 'something.example'
17 | r = Registry.create( D )
18 | assert r.ca.domain == D
19 |
20 |
21 | def test_root_ca():
22 | r = Registry.create()
23 | rr = r.obtain_registry_root()
24 | assert 'PRIVATE KEY' in rr.key
25 | assert 'CERTIFICATE' in rr.cert
26 | assert isinstance(rr.key, str)
27 | assert isinstance(rr.cert, str)
28 |
29 |
30 | def test_new_hosts():
31 | D = 'something.example'
32 | r = Registry.create( D )
33 |
34 | h1 = r.obtain_new_hostname()
35 | h2 = r.obtain_new_hostname('foo')
36 | h3 = r.obtain_new_hostname()
37 | h4 = r.obtain_new_hostname('bar')
38 |
39 | assert h1
40 | assert h2
41 | assert h3
42 | assert h4
43 | assert h1.startswith('unnamed')
44 | assert h2.startswith('foo')
45 | assert h3.startswith('unnamed')
46 | assert h4.startswith('bar')
47 | assert h1.endswith('.' + D)
48 | assert h2.endswith('.' + D)
49 | assert h3.endswith('.' + D)
50 | assert h4.endswith('.' + D)
51 |
52 |
53 | def test_new_host_and_cert():
54 | D = 'something.example'
55 | r = Registry.create( D )
56 |
57 | h1info = r.obtain_new_hostinfo()
58 |
59 | assert h1info.host.startswith('unnamed')
60 | assert h1info.host.endswith('.' + D)
61 | assert 'PRIVATE KEY' in h1info.key
62 | assert 'CERTIFICATE' in h1info.cert
63 | assert isinstance(h1info.key, str)
64 | assert isinstance(h1info.cert, str)
65 |
66 |
67 | def test_save_restore():
68 | D = 'something.example'
69 | r1 = Registry.create( D )
70 |
71 | for i in range(5):
72 | r1.obtain_new_hostinfo('')
73 |
74 | file = tempfile.NamedTemporaryFile(delete=True).name
75 | r1.save(file)
76 | r2 = Registry.load(file)
77 |
78 | assert r2.ca.domain == r1.ca.domain
79 | assert r2.ca.key == r1.ca.key
80 | assert r2.ca.cert == r1.ca.cert
81 |
82 | assert len(r2.hosts) == len(r1.hosts)
83 | for host in r2.hosts:
84 | hostinfo1 = r1.hosts[host]
85 | hostinfo2 = r2.hosts[host]
86 |
87 | assert hostinfo1
88 | assert hostinfo2
89 |
90 | assert hostinfo2.host == hostinfo1.host
91 | assert hostinfo2.key == hostinfo1.key
92 | assert hostinfo2.cert == hostinfo1.cert
93 |
94 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_passes.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a test that passes.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
9 | from feditest.testrun import TestRun
10 | from feditest.testruncontroller import AutomaticTestRunController
11 | from feditest import test
12 |
13 |
14 | @pytest.fixture(scope="module", autouse=True)
15 | def init_node_drivers():
16 | """
17 | Cleanly define the NodeDrivers.
18 | """
19 | feditest.all_node_drivers = {}
20 | feditest.load_default_node_drivers()
21 |
22 |
23 | @pytest.fixture(scope="module", autouse=True)
24 | def init_tests():
25 | """
26 | Cleanly define some tests.
27 | """
28 | feditest.all_tests = {}
29 | feditest._registered_as_test = {}
30 | feditest._registered_as_test_step = {}
31 | feditest._loading_tests = True
32 |
33 | ##
34 | ## FediTest tests start here
35 | ##
36 |
37 | @test
38 | def passes() -> None:
39 | """
40 | This test always passes.
41 | """
42 | return
43 |
44 | ##
45 | ## FediTest tests end here
46 | ## (Don't forget the next two lines)
47 | ##
48 |
49 | feditest._loading_tests = False
50 | feditest._load_tests_pass2()
51 |
52 |
53 | @pytest.fixture(autouse=True)
54 | def test_plan_fixture() -> TestPlan:
55 | """
56 | The test plan tests all known tests.
57 | """
58 | constellation = TestPlanConstellation({}, 'No nodes needed')
59 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
60 | session = TestPlanSessionTemplate(tests, "Test a test that passes")
61 | ret = TestPlan(session, [ constellation ])
62 | ret.properties_validate()
63 | return ret
64 |
65 |
66 | def test_run_testplan(test_plan_fixture: TestPlan):
67 | test_plan_fixture.check_can_be_executed()
68 |
69 | test_run = TestRun(test_plan_fixture)
70 | controller = AutomaticTestRunController(test_run)
71 | test_run.run(controller)
72 |
73 | transcript = test_run.transcribe()
74 | summary = transcript.build_summary()
75 |
76 | assert summary.n_total == 1
77 | assert summary.n_failed == 0
78 | assert summary.n_skipped == 0
79 | assert summary.n_errored == 0
80 | assert summary.n_passed == 1
81 |
82 | assert len(transcript.sessions) == 1
83 | assert len(transcript.sessions[0].run_tests) == 1
84 | assert transcript.sessions[0].run_tests[0].result is None
85 |
--------------------------------------------------------------------------------
/src/feditest/protocols/activitypub/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Abstractions for the ActivityPub protocol
3 | """
4 |
5 | from feditest.nodedrivers import NotImplementedByNodeError
6 | from feditest.protocols.web import WebServer
7 |
8 |
9 | class ActivityPubNode(WebServer):
10 | """
11 | A Node that can speak ActivityPub.
12 | """
13 | def obtain_actor_document_uri(self, rolename: str | None = None) -> str:
14 | """
15 | Smart factory method to return the https URI to an Actor document on this Node that
16 | either exists already or is newly created. Different rolenames produce different
17 | results; the same rolename produces the same result.
18 | rolename: refer to this Actor by this rolename; used to disambiguate multiple
19 | Actors on the same server by how they are used in tests
20 | return: the URI
21 | """
22 | raise NotImplementedByNodeError(self, ActivityPubNode.obtain_actor_document_uri)
23 |
24 | # You might expect lots of more methods here. Sorry to disappoint. But there's a reason:
25 | #
26 | # Example: you might expect a method that checks that some actor A is following actor B
27 | # (which is hosted on this ActivityPubNode). You might think that could be implemented
28 | # in one of the following ways:
29 | #
30 | # * an API call to the ActivityPubNode or a database query. Yep, it could, but that's
31 | # a lot of work to implement, and many applications don't have such an API.
32 | #
33 | # * find the following collection of actor B, and look into that collection. That would
34 | # require "something" to perform HTTP GET requests oforn B's actor document, and the
35 | # collection URIs. That works, but who is that "something"? It cannot be FediTest,
36 | # otherwise FediTest would become its own Node in the current Constellation, thereby
37 | # changing it quite a bit. This is particularly important when applications require
38 | # authorized fetch to fetch follower collections, and suddenly FediTest needs to
39 | # first exchange public keys etc. and for that it would have to be an HTTP server,
40 | # with DNS and TLS certs and nope, we are not going there.
41 | #
42 | # Instead, we ask an ActivityPubDiagNode in the Constellation to perform the fetch
43 | # of the followers collection on our behalf. It is already part of the Constellation
44 | # and likely has already exchanged keys.
45 | #
46 | # So: you find what you want on the "other" Node which is likeley an ActivityPubDiagNode
47 | # anyway.
48 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_skip.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a test that wants to be skipped.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest.nodedrivers import SkipTestException
9 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
10 | from feditest.testrun import TestRun
11 | from feditest.testruncontroller import AutomaticTestRunController
12 | from feditest import test
13 |
14 |
15 | @pytest.fixture(scope="module", autouse=True)
16 | def init_node_drivers():
17 | """
18 | Cleanly define the NodeDrivers.
19 | """
20 | feditest.all_node_drivers = {}
21 | feditest.load_default_node_drivers()
22 |
23 |
24 | @pytest.fixture(scope="module", autouse=True)
25 | def init_tests():
26 | """
27 | Cleanly define some tests.
28 | """
29 | feditest.all_tests = {}
30 | feditest._registered_as_test = {}
31 | feditest._registered_as_test_step = {}
32 | feditest._loading_tests = True
33 |
34 | ##
35 | ## FediTest tests start here
36 | ##
37 |
38 | @test
39 | def skip() -> None:
40 | """
41 | Always skips itself.
42 | """
43 | raise SkipTestException('We skipped this.')
44 |
45 | ##
46 | ## FediTest tests end here
47 | ## (Don't forget the next two lines)
48 | ##
49 |
50 | feditest._loading_tests = False
51 | feditest._load_tests_pass2()
52 |
53 |
54 | @pytest.fixture(autouse=True)
55 | def the_test_plan() -> TestPlan:
56 | """
57 | The test plan tests all known tests.
58 | """
59 |
60 | constellation = TestPlanConstellation({}, 'No nodes needed')
61 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
62 | session = TestPlanSessionTemplate(tests, "Test a test that wants to be skipped")
63 | ret = TestPlan(session, [ constellation ])
64 | ret.properties_validate()
65 | return ret
66 |
67 |
68 | def test_run_testplan(the_test_plan: TestPlan):
69 | the_test_plan.check_can_be_executed()
70 |
71 | test_run = TestRun(the_test_plan)
72 | controller = AutomaticTestRunController(test_run)
73 | test_run.run(controller)
74 |
75 | transcript = test_run.transcribe()
76 | summary = transcript.build_summary()
77 |
78 | assert summary.n_total == 1
79 | assert summary.n_failed == 0
80 | assert summary.n_skipped == 1
81 | assert summary.n_errored == 0
82 | assert summary.n_passed == 0
83 |
84 | assert len(transcript.sessions) == 1
85 | assert len(transcript.sessions[0].run_tests) == 1
86 | assert transcript.sessions[0].run_tests[0].result.type == 'SkipTestException'
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared_session/results.jinja2:
--------------------------------------------------------------------------------
1 | Constellation
2 |
3 | {%- for role_name, node in run_session.constellation.nodes.items() %}
4 |
5 |
{{ role_name | e }}
6 |
{{ local_name_with_tooltip(node.node_driver) }}
7 |
{{ node.appdata['app'] | e }}
8 |
{{ node.appdata['app_version'] or '?' | e }}
9 | {%- if node.parameters %}
10 |
11 |
12 |
13 | | Parameters: |
14 |
15 |
16 |
17 | {%- for key, value in node.parameters.items() %}
18 |
19 | | {{ key | e }} |
20 | {{ value | e }} |
21 |
22 | {%- endfor %}
23 |
24 |
25 | {%- endif %}
26 |
27 | {%- endfor %}
28 |
29 |
30 | Test Results
31 |
32 | {%- for test_index, run_test in enumerate(run_session.run_tests) %}
33 | {%- set plan_test_spec = transcript.plan.session_template.tests[run_test.plan_test_index] %}
34 | {%- set test_meta = transcript.test_meta[plan_test_spec.name] %}
35 |
36 |
Test: {{ test_meta.name | e }}
37 | {%- if test_meta.description %}
38 |
{{ test_meta.description | e }}
39 | {%- endif %}
40 |
Started {{ format_timestamp(run_test.started) }}, ended {{ format_timestamp(run_test.ended) }} (duration: {{ format_duration(run_test.ended - run_test.started) }})
41 | {%- with result=run_test.worst_result %}
42 | {%- include "partials/shared_session/testresult.jinja2" %}
43 | {%- endwith %}
44 | {%- for test_step_index, run_step in enumerate(run_test.run_steps or []) %}
45 |
46 | {% set test_step_meta = test_meta.steps[run_step.plan_step_index] %}
47 |
Test step: {{ test_step_meta.name | e }}
48 | {%- if test_step_meta.description %}
49 |
{{ test_step_meta.description | e }}
50 | {%- endif %}
51 |
Started {{ format_timestamp(run_test.started) }}, ended {{ format_timestamp(run_test.ended) }} (duration: {{ format_duration(run_test.ended - run_test.started) }})
52 | {%- with result=run_step.result, idmod='step' %}
53 | {%- include "partials/shared_session/testresult.jinja2" %}
54 | {%- endwith %}
55 |
56 | {%- endfor %}
57 |
58 | {%- endfor %}
59 |
60 |
--------------------------------------------------------------------------------
/tests.unit/test_40_fallback_fediverse_accounts_without_roles_from_testplan.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that Accounts and NonExistingAccounts are parsed correctly when given in a TestPlan that
3 | specifies a FallbackFediverseNode. This is the test in which the Accounts do not have pre-assigned roles.
4 | """
5 |
6 | from typing import cast
7 |
8 | import pytest
9 |
10 | import feditest
11 | from feditest.nodedrivers.saas import FediverseSaasNodeDriver
12 | from feditest.protocols.fediverse import (
13 | USERID_ACCOUNT_FIELD,
14 | USERID_NON_EXISTING_ACCOUNT_FIELD,
15 | FediverseAccount,
16 | FediverseNonExistingAccount
17 | )
18 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate
19 |
20 |
21 | HOSTNAME = 'localhost'
22 | NODE1_ROLE = 'node1-role'
23 |
24 |
25 | @pytest.fixture(scope="module", autouse=True)
26 | def init():
27 | """ Clean init """
28 | feditest.all_tests = {}
29 | feditest._registered_as_test = {}
30 | feditest._registered_as_test_step = {}
31 | feditest._loading_tests = True
32 |
33 | feditest._loading_tests = False
34 | feditest._load_tests_pass2()
35 |
36 |
37 | @pytest.fixture(autouse=True)
38 | def test_plan_fixture() -> TestPlan:
39 | node_driver = FediverseSaasNodeDriver()
40 | parameters = {
41 | 'hostname' : 'example.com', # Avoid interactive question
42 | 'app' : 'test-dummy' # Avoid interactive question
43 | }
44 | plan_accounts = [
45 | {
46 | USERID_ACCOUNT_FIELD.name : 'foo'
47 | }
48 | ]
49 | plan_non_existing_accounts = [
50 | {
51 | USERID_NON_EXISTING_ACCOUNT_FIELD.name : 'nonfoo'
52 | }
53 | ]
54 | node1 = TestPlanConstellationNode(node_driver, parameters, plan_accounts, plan_non_existing_accounts)
55 | constellation = TestPlanConstellation({ NODE1_ROLE : node1 })
56 | session_template = TestPlanSessionTemplate([])
57 | ret = TestPlan(session_template, [ constellation ])
58 | ret.properties_validate()
59 | return ret
60 |
61 |
62 | def test_populate(test_plan_fixture: TestPlan) -> None:
63 | """
64 | Tests parsing the TestPlan
65 | """
66 | node1 = test_plan_fixture.constellations[0].roles[NODE1_ROLE]
67 | node_driver = node1.nodedriver
68 |
69 | node_config, account_manager = node_driver.create_configuration_account_manager(NODE1_ROLE, node1)
70 | node_driver.provision_node('test', node_config, account_manager)
71 |
72 | acc1 = cast(FediverseAccount | None, account_manager.get_account_by_role('role1'))
73 |
74 | assert acc1 is None
75 |
76 | acc1 = account_manager.obtain_account_by_role('role1')
77 |
78 | assert acc1
79 | assert acc1.role is None
80 | assert acc1.actor_acct_uri == 'acct:foo@example.com'
81 |
82 | non_acc1 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('nonrole1'))
83 |
84 | assert non_acc1 is None
85 |
86 | non_acc1 = account_manager.obtain_non_existing_account_by_role('nonrole1')
87 |
88 | assert non_acc1
89 | assert non_acc1.role is None
90 | assert non_acc1.actor_acct_uri == 'acct:nonfoo@example.com'
91 |
92 |
--------------------------------------------------------------------------------
/tests.unit/test_30_create_testplan.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the equivalent of `feditest create-testplan`
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import test
9 | from feditest.nodedrivers import Node
10 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
11 |
12 |
13 | @pytest.fixture(scope="module", autouse=True)
14 | def init():
15 | """ Keep these isolated to this module """
16 | feditest.all_tests = {}
17 | feditest._registered_as_test = {}
18 | feditest._registered_as_test_step = {}
19 | feditest._loading_tests = True
20 |
21 | @test
22 | def test1(role_a: Node) -> None:
23 | return
24 |
25 | @test
26 | def test2(role_a: Node) -> None:
27 | return
28 |
29 | @test
30 | def test3(role_b: Node) -> None:
31 | return
32 |
33 | feditest._loading_tests = False
34 | feditest._load_tests_pass2()
35 |
36 |
37 | @pytest.fixture
38 | def test_specs() -> list[TestPlanTestSpec]:
39 | return [
40 | TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None
41 | ]
42 |
43 |
44 | @pytest.fixture
45 | def unnamed_constellations() -> list[TestPlanConstellation]:
46 | return [
47 | TestPlanConstellation({'role_a': None, 'role_b': None}, None),
48 | TestPlanConstellation({'role_a': None, 'role_b': None, 'role_c': None}, None)
49 | ]
50 |
51 |
52 | @pytest.fixture
53 | def unnamed_session_template(test_specs: list[TestPlanTestSpec]) -> TestPlanSessionTemplate:
54 | return TestPlanSessionTemplate(test_specs)
55 |
56 |
57 | def construct_testplan(constellations: list[TestPlanConstellation], session_template: TestPlanSessionTemplate, testplan_name: str) -> TestPlan:
58 | """
59 | Helper to put it together.
60 | """
61 | test_plan = TestPlan(session_template, constellations, testplan_name)
62 | test_plan.properties_validate()
63 | test_plan.simplify()
64 |
65 | return test_plan
66 |
67 |
68 | def test_structure(unnamed_constellations: list[TestPlanConstellation], unnamed_session_template: TestPlanSessionTemplate) -> None:
69 | """
70 | Test the structure of the TestPlan, ignore the naming.
71 | """
72 | test_plan = construct_testplan(unnamed_constellations, unnamed_session_template, None)
73 | assert test_plan.session_template
74 | assert len(test_plan.constellations) == 2
75 |
76 |
77 | def test_all_unnamed(unnamed_constellations: list[TestPlanConstellation], unnamed_session_template: TestPlanSessionTemplate) -> None:
78 | """
79 | Only test the naming.
80 | """
81 | test_plan = construct_testplan(unnamed_constellations, unnamed_session_template, None)
82 | assert test_plan.name is None
83 | assert str(test_plan) == "Unnamed"
84 |
85 |
86 | def test_testplan_named(unnamed_constellations: list[TestPlanConstellation], unnamed_session_template: TestPlanSessionTemplate) -> None:
87 | """
88 | Only test the naming.
89 | """
90 | TESTPLAN_NAME = 'My test plan'
91 | test_plan = construct_testplan(unnamed_constellations, unnamed_session_template, TESTPLAN_NAME)
92 | assert test_plan.name == TESTPLAN_NAME
93 | assert str(test_plan) == TESTPLAN_NAME
94 |
95 |
--------------------------------------------------------------------------------
/tests.smoke/tests/nodes_with_mastodon_api_communicate.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests that two nodes that implement the Mastodon API can follow each other.
3 | """
4 |
5 | from datetime import datetime
6 | import time
7 |
8 | from feditest import poll_until, step, test
9 | from feditest.nodedrivers.mastodon import NodeWithMastodonAPI
10 |
11 |
12 | @test
13 | class FollowTest:
14 | def __init__(self,
15 | leader_node: NodeWithMastodonAPI,
16 | follower_node: NodeWithMastodonAPI
17 | ) -> None:
18 | self.leader_node = leader_node
19 | self.leader_actor_acct_uri = None
20 |
21 | self.follower_node = follower_node
22 | self.follower_actor_acct_uri = None
23 |
24 | self.leader_note_uri = None
25 |
26 |
27 | @step
28 | def provision_actors(self):
29 | self.leader_actor_acct_uri = self.leader_node.obtain_actor_acct_uri()
30 | assert self.leader_actor_acct_uri
31 | self.leader_node.set_auto_accept_follow(self.leader_actor_acct_uri, True)
32 |
33 | self.follower_actor_acct_uri = self.follower_node.obtain_actor_acct_uri()
34 | assert self.follower_actor_acct_uri
35 |
36 |
37 | # @step
38 | # def start_reset_all(self):
39 | # self._reset_all()
40 |
41 |
42 | @step
43 | def follow(self):
44 | self.follower_node.make_follow(self.follower_actor_acct_uri, self.leader_actor_acct_uri)
45 |
46 |
47 | @step
48 | def wait_until_actor_is_followed_by_actor(self):
49 | time.sleep(1) # Sometimes there seems to be a race condition in Mastodon, or something like that.
50 | # If we proceed too quickly, the API returns 422 "User already exists" or such
51 | # in response to a search, which makes no sense.
52 | poll_until(lambda: self.leader_node.actor_is_followed_by_actor(self.leader_actor_acct_uri, self.follower_actor_acct_uri))
53 |
54 |
55 | @step
56 | def wait_until_actor_is_following_actor(self):
57 | poll_until(lambda: self.follower_node.actor_is_following_actor(self.follower_actor_acct_uri, self.leader_actor_acct_uri))
58 |
59 |
60 | @step
61 | def leader_creates_note(self):
62 | self.leader_note_uri = self.leader_node.make_create_note(self.leader_actor_acct_uri, f"testing leader_creates_note {datetime.now()}")
63 | assert self.leader_note_uri
64 |
65 |
66 | @step
67 | def wait_until_note_received(self):
68 | poll_until(lambda: self.follower_node.actor_has_received_object(self.follower_actor_acct_uri, self.leader_note_uri))
69 |
70 |
71 | # @step
72 | # def end_reset_all(self):
73 | # self._reset_all()
74 |
75 |
76 | def _reset_all(self):
77 | """
78 | Clean up data. This is here so the test is usable with non-brand-new instances.
79 | """
80 | self.leader_node.delete_all_followers_of(self.leader_actor_acct_uri)
81 | self.leader_node.delete_all_following_of(self.leader_actor_acct_uri)
82 | self.leader_node.delete_all_statuses_by(self.leader_actor_acct_uri)
83 |
84 | self.follower_node.delete_all_followers_of(self.follower_actor_acct_uri)
85 | self.follower_node.delete_all_following_of(self.follower_actor_acct_uri)
86 | self.follower_node.delete_all_statuses_by(self.follower_actor_acct_uri)
87 |
88 |
--------------------------------------------------------------------------------
/tests.unit/test_40_fallback_fediverse_accounts_with_roles_from_testplan.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that Accounts and NonExistingAccounts are parsed correctly when given in a TestPlan that
3 | specifies a FallbackFediverseNode. This is the test in which the Accounts have pre-assigned roles.
4 | """
5 |
6 | from typing import cast
7 |
8 | import pytest
9 |
10 | import feditest
11 | from feditest.nodedrivers.saas import FediverseSaasNodeDriver
12 | from feditest.protocols.fediverse import (
13 | ROLE_ACCOUNT_FIELD,
14 | ROLE_NON_EXISTING_ACCOUNT_FIELD,
15 | USERID_ACCOUNT_FIELD,
16 | USERID_NON_EXISTING_ACCOUNT_FIELD,
17 | FediverseAccount,
18 | FediverseNonExistingAccount
19 | )
20 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate
21 |
22 |
23 | HOSTNAME = 'localhost'
24 | NODE1_ROLE = 'node1-role'
25 |
26 |
27 | @pytest.fixture(scope="module", autouse=True)
28 | def init():
29 | """ Clean init """
30 | feditest.all_tests = {}
31 | feditest._registered_as_test = {}
32 | feditest._registered_as_test_step = {}
33 | feditest._loading_tests = True
34 |
35 | feditest._loading_tests = False
36 | feditest._load_tests_pass2()
37 |
38 |
39 | @pytest.fixture(autouse=True)
40 | def test_plan_fixture() -> TestPlan:
41 | node_driver = FediverseSaasNodeDriver()
42 | parameters = {
43 | 'hostname' : 'example.com', # Avoid interactive question
44 | 'app' : 'test-dummy' # Avoid interactive question
45 | }
46 | plan_accounts = [
47 | {
48 | ROLE_ACCOUNT_FIELD.name : 'role1',
49 | USERID_ACCOUNT_FIELD.name : 'foo'
50 | }
51 | ]
52 | plan_non_existing_accounts = [
53 | {
54 | ROLE_NON_EXISTING_ACCOUNT_FIELD.name : 'nonrole1',
55 | USERID_NON_EXISTING_ACCOUNT_FIELD.name : 'nonfoo'
56 | }
57 | ]
58 | node1 = TestPlanConstellationNode(node_driver, parameters, plan_accounts, plan_non_existing_accounts)
59 | constellation = TestPlanConstellation({ NODE1_ROLE : node1 })
60 | session_template = TestPlanSessionTemplate([])
61 | ret = TestPlan(session_template, [ constellation ])
62 | ret.properties_validate()
63 | return ret
64 |
65 |
66 | def test_populate(test_plan_fixture: TestPlan) -> None:
67 | """
68 | Tests parsing the TestPlan
69 | """
70 | node1 = test_plan_fixture.constellations[0].roles[NODE1_ROLE]
71 | node_driver = node1.nodedriver
72 |
73 | node_config, account_manager = node_driver.create_configuration_account_manager(NODE1_ROLE, node1)
74 | node_driver.provision_node('test', node_config, account_manager)
75 |
76 | acc1 = cast(FediverseAccount | None, account_manager.get_account_by_role('role1'))
77 |
78 | assert acc1
79 | assert acc1.role == 'role1'
80 | assert acc1.actor_acct_uri == 'acct:foo@example.com'
81 |
82 | acc11 = account_manager.obtain_account_by_role('role1')
83 | assert acc11 == acc1
84 |
85 | non_acc1 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('nonrole1'))
86 | assert non_acc1
87 | assert non_acc1.role == 'nonrole1'
88 | assert non_acc1.actor_acct_uri == 'acct:nonfoo@example.com'
89 |
90 | non_acc11 = account_manager.obtain_non_existing_account_by_role('nonrole1')
91 | assert non_acc11 == non_acc1
92 |
93 |
--------------------------------------------------------------------------------
/RELEASE-HOWTO.md:
--------------------------------------------------------------------------------
1 | # Release How-To
2 |
3 | ## Smoke test and test with sandbox
4 |
5 | 1. On the Mac:
6 | 1. Repo `feditest`: `git checkout develop`
7 | 1. In `pyproject.toml`, change `project` / `version` to the new version `VERSION` (needed so the generated files have the right version in them before check-in)
8 | 1. Clean rebuild:
9 | 1. `rm -rf venv.*`
10 | 1. `make venv`
11 | 1. `make lint`: ruff and mypy show no errors
12 | 1. `make tests`: unit tests show no errors (smoke tests don't run on macOS)
13 | 1. Repo `feditest-tests-sandbox`: `git checkout develop`
14 | 1. Clean re-run and report generation of the sandbox tests:
15 | 1. `make -f Makefile.create clean FEDITEST=../feditest/venv.darwin.default/bin/feditest`
16 | 1. `make -f Makefile.run clean FEDITEST=../feditest/venv.darwin.default/bin/feditest`
17 | 1. `make -f Makefile.create examples FEDITEST=../feditest/venv.darwin.default/bin/feditest`
18 | 1. `make -f Makefile.run sandbox FEDITEST=../feditest/venv.darwin.default/bin/feditest`
19 | 1. `open examples/testresults/*.html` and check for plausibility of reports
20 |
21 | ## Smoke test and test quickstart
22 |
23 | 1. On UBOS:
24 | 1. Repo `feditest`: `git checkout develop`
25 | 1. Clean rebuild:
26 | 1. `rm -rf venv.*`
27 | 1. `make venv`
28 | 1. `make lint`: ruff and mypy show no errors
29 | 1. `make tests`: unit tests and smoke tests show no errors (other than WordPress timeout-related)
30 | 1. Repo `feditest-tests-fediverse`: `git checkout develop`
31 | 1. Clean re-run and report generation of the quickstart tests:
32 | 1. Run quickstart examples from https://feditest.org/quickstart/evaluate/, but instead of `feditest` use `../feditest/venv.linux.default/bin/feditest`.
33 | 1. `xdg-open results/*.html` and check for plausibility of reports
34 |
35 | ## Tag versions
36 |
37 | 1. On the Mac:
38 | 1. Update repo `feditest-tests-fediverse`, branch `develop`:
39 | 1. `git tag -a vVERSION -m vVERSION`
40 | 1. `git push`
41 | 1. `git push --tags`
42 | 1. Update repo `feditest-tests-sandbox`, branch `develop`:
43 | 1. `git tag -a vVERSION -m vVERSION`
44 | 1. `git push`
45 | 1. `git push --tags`
46 | 1. Update repo `feditest`, branch `develop`:
47 | 1. `git tag -a vVERSION -m vVERSION`
48 | 1. `git push`
49 | 1. `git push --tags`
50 |
51 | ## Merge into main
52 |
53 | 1. Repo `feditest-tests-fediverse`: pull request `develop` into `main`
54 | 1. Repo `feditest-tests-sandbox`: pull request `develop` into `main`
55 | 1. Repo `feditest`: pull request `develop` into `main`
56 | 1. Approve all three pull requests
57 |
58 | ## Publish to PyPi
59 |
60 | 1. On the Mac:
61 | 1. Repo `feditest`, branch `main`
62 | 1. `make release`
63 | 1. `venv.release/bin/twine upload dist/*`
64 | 1. `pip3.11 install --upgrade feditest`
65 | 1. `feditest version` now shows `VERSION`
66 |
67 | ## Publish to UBOS repos
68 |
69 | 1. On UBOS:
70 | 1. Build `feditest` for the UBOS package repos so it can be installed with `pacman -S feditest`
71 |
72 | ## Create release notes
73 |
74 | 1. Repo: `feditest.org`: create release notes
75 | 1. `git push`
76 |
77 | ## Announce
78 |
79 | 1. `https://matrix.to/#/#fediverse-testing:matrix.org`: post link to release notes
80 | 1. `@feditest@mastodon.social`: post link to release notes
81 |
--------------------------------------------------------------------------------
/tests.unit/test_40_report_node_driver_errors.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that NodeDriver errors are reported in the test reports
3 | """
4 |
5 | import feditest
6 | import pytest
7 | from feditest import nodedriver
8 | from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver
9 | from feditest.testplan import (
10 | TestPlan,
11 | TestPlanConstellation,
12 | TestPlanConstellationNode,
13 | TestPlanSessionTemplate,
14 | TestPlanTestSpec,
15 | )
16 | from feditest.testrun import TestRun
17 | from feditest.testruncontroller import AutomaticTestRunController
18 | from feditest.testruntranscriptserializer.json import JsonTestRunTranscriptSerializer
19 | from feditest.testruntranscriptserializer.summary import SummaryTestRunTranscriptSerializer
20 | from feditest.testruntranscriptserializer.tap import TapTestRunTranscriptSerializer
21 |
22 |
23 | class NodeDriverTestException(Exception):
24 | pass
25 |
26 |
27 | @pytest.fixture(scope="module", autouse=True)
28 | def init():
29 | global node_driver_name
30 |
31 | """ Keep these isolated to this module """
32 | feditest.all_tests = {}
33 | feditest._registered_as_test = {}
34 | feditest._registered_as_test_step = {}
35 | feditest._loading_tests = True
36 |
37 | @feditest.test
38 | def dummy() -> None:
39 | return
40 |
41 | feditest._loading_tests = False
42 | feditest._load_tests_pass2()
43 |
44 | """ The NodeDriver we use for testing """
45 | feditest.all_node_drivers = {}
46 | feditest._loading_node_drivers = True
47 |
48 | @nodedriver
49 | class Faulty_NodeDriver(NodeDriver):
50 | def _provision_node(
51 | self,
52 | rolename: str,
53 | config: NodeConfiguration,
54 | account_manager: AccountManager | None
55 | ) -> Node:
56 | raise NodeDriverTestException()
57 |
58 | feditest._loading_node_drivers = False
59 |
60 | for t in feditest.all_tests:
61 | print( f'TEST: { t }')
62 |
63 |
64 | def test_faulty_node_driver_reporting() -> None:
65 | plan = TestPlan(
66 | TestPlanSessionTemplate(
67 | [
68 | TestPlanTestSpec('test_40_report_node_driver_errors::init..dummy')
69 | ]
70 | ),
71 | [
72 | TestPlanConstellation( {
73 | 'node' : TestPlanConstellationNode(
74 | nodedriver = 'test_40_report_node_driver_errors.init..Faulty_NodeDriver',
75 | parameters = { 'app' : 'Dummy for test_faulty_node_driver_reporting'}
76 | )
77 | }),
78 | ]
79 | )
80 | plan.properties_validate()
81 | run = TestRun(plan)
82 | controller = AutomaticTestRunController(run)
83 |
84 | run.run(controller)
85 |
86 | transcript : feditest.testruntranscript.TestRunTranscript = run.transcribe()
87 | # transcript.save('transcript.json')
88 |
89 | summary = SummaryTestRunTranscriptSerializer().write_to_string(transcript)
90 | # print(summary)
91 | assert 'errors=1' in summary
92 |
93 | tap = TapTestRunTranscriptSerializer().write_to_string(transcript)
94 | # print(tap)
95 | assert 'errors: 1' in tap
96 |
97 | j = JsonTestRunTranscriptSerializer().write_to_string(transcript)
98 | # print(j)
99 | assert f'"type": "{NodeDriverTestException.__name__}"' in j
100 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_not_implemented.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a test that throws a NotImplemented error.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver, NotImplementedByNodeError
9 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
10 | from feditest.testrun import TestRun
11 | from feditest.testruncontroller import AutomaticTestRunController
12 | from feditest import test
13 |
14 |
15 | class DummyNode(Node):
16 | def missing_method(self):
17 | pass
18 |
19 |
20 | class DummyNodeDriver(NodeDriver):
21 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> Node:
22 | return DummyNode(rolename, config, account_manager)
23 |
24 |
25 | def missing_method(self):
26 | pass
27 |
28 |
29 | @pytest.fixture(scope="module", autouse=True)
30 | def init_node_drivers():
31 | """
32 | Cleanly define the NodeDrivers.
33 | """
34 | feditest.all_node_drivers = {}
35 | feditest.load_default_node_drivers()
36 |
37 |
38 | @pytest.fixture(scope="module", autouse=True)
39 | def init_tests():
40 | """
41 | Cleanly define some tests.
42 | """
43 | feditest.all_tests = {}
44 | feditest._registered_as_test = {}
45 | feditest._registered_as_test_step = {}
46 | feditest._loading_tests = True
47 |
48 | ##
49 | ## FediTest tests start here
50 | ##
51 |
52 | @test
53 | def not_implemented_by_node_error() -> None:
54 | """
55 | A Node does not implement a method.
56 | """
57 | driver = DummyNodeDriver()
58 | node = driver.provision_node('testrole', NodeConfiguration(driver, 'dummy'))
59 |
60 | raise NotImplementedByNodeError(node, DummyNode.missing_method)
61 |
62 | ##
63 | ## FediTest tests end here
64 | ## (Don't forget the next two lines)
65 | ##
66 |
67 | feditest._loading_tests = False
68 | feditest._load_tests_pass2()
69 |
70 |
71 | @pytest.fixture(autouse=True)
72 | def test_plan_fixture() -> TestPlan:
73 | """
74 | The test plan tests all known tests.
75 | """
76 |
77 | constellation = TestPlanConstellation({}, 'No nodes needed')
78 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
79 | session = TestPlanSessionTemplate(tests, "Test tests that throw NotImplemented errors")
80 | ret = TestPlan(session, [ constellation ])
81 | ret.properties_validate()
82 | return ret
83 |
84 |
85 | def test_run_testplan(test_plan_fixture: TestPlan):
86 | test_plan_fixture.check_can_be_executed()
87 |
88 | test_run = TestRun(test_plan_fixture)
89 | controller = AutomaticTestRunController(test_run)
90 | test_run.run(controller)
91 |
92 | transcript = test_run.transcribe()
93 | summary = transcript.build_summary()
94 |
95 | assert summary.n_total == 1
96 | assert summary.n_failed == 0
97 | assert summary.n_skipped == 1 # NotImplemented exceptions cause skips
98 | assert summary.n_errored == 0
99 | assert summary.n_passed == 0
100 |
101 | assert len(transcript.sessions) == 1
102 | assert len(transcript.sessions[0].run_tests) == 1
103 | assert transcript.sessions[0].run_tests[0].result.type == 'NotImplementedByNodeError'
--------------------------------------------------------------------------------
/src/feditest/cli/commands/convert_transcript.py:
--------------------------------------------------------------------------------
1 | """
2 | Convert a TestRunTranscript to a different format
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 |
7 | from feditest.reporting import warning
8 | from feditest.testruntranscript import TestRunTranscript
9 | from feditest.testruntranscriptserializer.json import JsonTestRunTranscriptSerializer
10 | from feditest.testruntranscriptserializer.html import HtmlRunTranscriptSerializer
11 | from feditest.testruntranscriptserializer.summary import SummaryTestRunTranscriptSerializer
12 | from feditest.testruntranscriptserializer.tap import TapTestRunTranscriptSerializer
13 | from feditest.utils import FEDITEST_VERSION
14 |
15 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
16 | """
17 | Run this command.
18 | """
19 |
20 | transcript = TestRunTranscript.load(args.in_file)
21 | if not transcript.is_compatible_type():
22 | warning(f'Transcript has unexpected type { transcript.type }: incompatibilities may occur.')
23 |
24 | if not transcript.has_compatible_version():
25 | warning(f'Transcript was created by FediTest { transcript.feditest_version }, you are running FediTest { FEDITEST_VERSION }: incompatibilities may occur.')
26 |
27 | if isinstance(args.html, str):
28 | HtmlRunTranscriptSerializer(args.template_path).write(transcript, args.html)
29 | elif args.html:
30 | warning('--html requires a filename: skipping')
31 | elif args.template_path:
32 | warning('--template-path only supported with --html. Ignoring.')
33 |
34 | if isinstance(args.tap, str) or args.tap:
35 | TapTestRunTranscriptSerializer().write(transcript, args.tap)
36 |
37 | if isinstance(args.json, str) or args.json:
38 | JsonTestRunTranscriptSerializer().write(transcript, args.json)
39 |
40 | if isinstance(args.summary, str) or args.summary:
41 | SummaryTestRunTranscriptSerializer().write(transcript, args.summary)
42 |
43 | return 0
44 |
45 |
46 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
47 | """
48 | Add command-line options for this sub-command
49 | parent_parser: the parent argparse parser
50 | cmd_name: name of this command
51 | """
52 | parser = parent_parser.add_parser(cmd_name, help='Convert a transcript of a TestRun to a different format')
53 | parser.add_argument('--in', required=True, dest="in_file", help='JSON file containing the transcript')
54 | parser.add_argument('--tap', nargs="?", const=True, default=False,
55 | help="Write results in TAP format to stdout, or to the provided file (if given).")
56 | html_group = parser.add_argument_group('html', 'HTML options')
57 | html_group.add_argument('--html',
58 | help="Write results in HTML format to the provided file.")
59 | html_group.add_argument('--template-path', required=False,
60 | help="When specifying --html, use this template path override (comma separated directory names)")
61 | parser.add_argument('--json', nargs="?", const=True, default=False,
62 | help="Write results in JSON format to stdout, or to the provided file (if given).")
63 | parser.add_argument('--summary', nargs="?", const=True, default=False,
64 | help="Write summary to stdout, or to the provided file (if given). This is the default if no other output option is given")
65 |
66 | return parser
67 |
--------------------------------------------------------------------------------
/src/feditest/protocols/webfinger/abstract.py:
--------------------------------------------------------------------------------
1 | """
2 | Functionality that may be shared by several WebFinger Node implementations.
3 | """
4 |
5 | from typing import cast
6 |
7 | from feditest.protocols.web.diag import HttpRequest, HttpRequestResponsePair, WebDiagClient
8 | from feditest.protocols.webfinger import WebFingerServer
9 | from feditest.protocols.webfinger.diag import ClaimedJrd, WebFingerDiagClient
10 | from feditest.protocols.webfinger.utils import construct_webfinger_uri_for, WebFingerQueryDiagResponse
11 | from feditest.utils import ParsedUri
12 |
13 |
14 | class AbstractWebFingerDiagClient(WebFingerDiagClient):
15 | # Python 3.12 @override
16 | def diag_perform_webfinger_query(
17 | self,
18 | resource_uri: str,
19 | rels: list[str] | None = None,
20 | server: WebFingerServer | None = None
21 | ) -> WebFingerQueryDiagResponse:
22 |
23 | query_url = construct_webfinger_uri_for(resource_uri, rels, server.hostname() if server else None )
24 | parsed_uri = ParsedUri.parse(query_url)
25 | if not parsed_uri:
26 | raise ValueError('Not a valid URI:', query_url) # can't avoid this
27 |
28 | first_request = HttpRequest(parsed_uri)
29 | current_request = first_request
30 | pair : HttpRequestResponsePair | None = None
31 | for redirect_count in range(10, 0, -1):
32 | pair = self.http(current_request)
33 | if pair.response and pair.response.is_redirect():
34 | if redirect_count <= 0:
35 | return WebFingerQueryDiagResponse(pair, None, [ WebDiagClient.TooManyRedirectsError(current_request) ])
36 | parsed_location_uri = ParsedUri.parse(pair.response.location())
37 | if not parsed_location_uri:
38 | return WebFingerQueryDiagResponse(pair, None, [ ValueError('Location header is not a valid URI:', query_url, '(from', resource_uri, ')') ] )
39 | current_request = HttpRequest(parsed_location_uri)
40 | break
41 |
42 | # I guess we always have a non-null responses here, but mypy complains without the cast
43 | pair = cast(HttpRequestResponsePair, pair)
44 | ret_pair = HttpRequestResponsePair(first_request, current_request, pair.response)
45 | if ret_pair.response is None:
46 | raise RuntimeError('Unexpected None HTTP response')
47 |
48 | excs : list[Exception] = []
49 | if ret_pair.response.http_status != 200:
50 | excs.append(WebFingerDiagClient.WrongHttpStatusError(ret_pair))
51 |
52 | content_type = ret_pair.response.content_type()
53 | if (content_type is None or (content_type != "application/jrd+json"
54 | and not content_type.startswith( "application/jrd+json;" ))
55 | ):
56 | excs.append(WebFingerDiagClient.WrongContentTypeError(ret_pair))
57 |
58 | jrd : ClaimedJrd | None = None
59 |
60 | if ret_pair.response.payload is None:
61 | raise RuntimeError('Unexpected None payload in HTTP response')
62 |
63 | try:
64 | json_string = ret_pair.response.payload.decode(encoding=ret_pair.response.payload_charset() or "utf8")
65 |
66 | jrd = ClaimedJrd(json_string) # May throw JSONDecodeError
67 | jrd.validate() # May throw JrdError
68 | except ExceptionGroup as exc:
69 | excs += exc.exceptions
70 | except Exception as exc:
71 | excs.append(exc)
72 |
73 | return WebFingerQueryDiagResponse(ret_pair, jrd, excs)
74 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/tap.py:
--------------------------------------------------------------------------------
1 | from typing import IO
2 |
3 | from feditest.testruntranscript import TestRunTranscript
4 | from feditest.testruntranscriptserializer import FileOrStdoutTestRunTranscriptSerializer
5 |
6 |
7 | class TapTestRunTranscriptSerializer(FileOrStdoutTestRunTranscriptSerializer):
8 | """
9 | Knows how to serialize a TestRunTranscript into a report in TAP format.
10 | """
11 | def _write(self, transcript: TestRunTranscript, fd: IO[str]):
12 | plan = transcript.plan
13 | summary = transcript.build_summary()
14 |
15 | fd.write("TAP version 14\n")
16 | fd.write(f"# test plan: { plan }\n")
17 | for key in ['started', 'ended', 'platform', 'username', 'hostname']:
18 | value = getattr(transcript, key)
19 | if value:
20 | fd.write(f"# {key}: {value}\n")
21 |
22 | test_id = 0
23 | for session_transcript in transcript.sessions:
24 | plan_session_template = plan.session_template
25 | constellation = session_transcript.constellation
26 |
27 | fd.write(f"# session: { session_transcript }\n")
28 | fd.write(f"# constellation: { constellation }\n")
29 | fd.write("# roles:\n")
30 | for role_name, node in constellation.nodes.items():
31 | if role_name in session_transcript.constellation.nodes:
32 | transcript_role = session_transcript.constellation.nodes[role_name]
33 | fd.write(f"# - name: {role_name}\n")
34 | if node:
35 | fd.write(f"# driver: {node.node_driver}\n")
36 | fd.write(f"# app: {transcript_role.appdata['app']}\n")
37 | fd.write(f"# app_version: {transcript_role.appdata['app_version'] or '?'}\n")
38 | else:
39 | fd.write(f"# - name: {role_name} -- not instantiated\n")
40 |
41 | for test_index, run_test in enumerate(session_transcript.run_tests):
42 | test_id += 1
43 |
44 | plan_test_spec = plan_session_template.tests[run_test.plan_test_index]
45 | test_meta = transcript.test_meta[plan_test_spec.name]
46 |
47 | result = run_test.worst_result
48 | if result:
49 | fd.write(f"not ok {test_id} - {test_meta.name}\n")
50 | fd.write(" ---\n")
51 | fd.write(f" problem: {result.type} ({ result.spec_level }, { result.interop_level })\n")
52 | if result.msg:
53 | fd.write(" message:\n")
54 | fd.write("\n".join( [ f" { p }" for p in result.msg.strip().split("\n") ] ) + "\n")
55 | fd.write(" where:\n")
56 | for loc in result.stacktrace:
57 | fd.write(f" {loc[0]} {loc[1]}\n")
58 | fd.write(" ...\n")
59 | else:
60 | directives = "" # FIXME f" # SKIP {test.skip}" if test.skip else ""
61 | fd.write(f"ok {test_id} - {test_meta.name}{directives}\n")
62 |
63 | fd.write(f"1..{test_id}\n")
64 | fd.write("# test run summary:\n")
65 | fd.write(f"# total: {summary.n_total}\n")
66 | fd.write(f"# passed: {summary.n_passed}\n")
67 | fd.write(f"# failed: {summary.n_failed}\n")
68 | fd.write(f"# skipped: {summary.n_skipped}\n")
69 | fd.write(f"# errors: {summary.n_errored}\n")
70 |
--------------------------------------------------------------------------------
/src/feditest/protocols/webfinger/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Abstractions for the WebFinger protocol
3 | """
4 |
5 | from feditest.nodedrivers import NotImplementedByNodeError
6 | from feditest.protocols.web import WebClient, WebServer
7 |
8 |
9 | class WebFingerClient(WebClient):
10 | """
11 | A Node that acts as a WebFinger client.
12 | """
13 | def perform_webfinger_query(self, resource_uri: str) -> None:
14 | """
15 | Make this Node perform a WebFinger query for the provided resource_uri.
16 | The resource_uri must be a valid, absolute URI, such as 'acct:foo@bar.com` or
17 | 'https://example.com/aabc' (not escaped).
18 | This returns None as it is unreasonable to assume that a non-diag Node can implement
19 | this call otherwise. However, it may throw exceptions.
20 | It is used with a WebFingerDiagServer to determine whether this WebFingerClient performs
21 | valid WebFinger queries.
22 | """
23 | raise NotImplementedByNodeError(self, WebFingerClient.perform_webfinger_query)
24 |
25 |
26 | class WebFingerServer(WebServer):
27 | """
28 | A Node that acts as a WebFinger server.
29 |
30 | The implementation code in this class is here entirely for fallback purposes. Given this,
31 | we are not trying to manage the collection behind the smart factory methods.
32 | """
33 | def obtain_account_identifier(self, rolename: str | None = None) -> str:
34 | """
35 | Smart factory method to return the identifier to an account on this Node that
36 | a client is supposed to be able to perform WebFinger resolution on. Different
37 | rolenames produce different results; the same rolename produces the same result.
38 | The identifier is of the form ``acct:foo@bar.com``.
39 | rolename: refer to this account by this rolename; used to disambiguate multiple
40 | accounts on the same server by how they are used in tests
41 | return: the identifier
42 | """
43 | raise NotImplementedByNodeError(self, WebFingerServer.obtain_account_identifier)
44 |
45 |
46 | def obtain_non_existing_account_identifier(self, rolename: str | None = None ) -> str:
47 | """
48 | Smart factory method to return the identifier of an account that does not exist on this Node,
49 | but that nevertheless follows the rules for identifiers of this Node. Different rolenames
50 | produce different results; the same rolename produces the same result.
51 | The identifier is of the form ``acct:foo@bar.com``.
52 | rolename: refer to this account by this rolename; used to disambiguate multiple
53 | accounts on the same server by how they are used in tests
54 | return: the identifier
55 | """
56 | raise NotImplementedByNodeError(self, WebFingerServer.obtain_non_existing_account_identifier)
57 |
58 |
59 | def obtain_account_identifier_requiring_percent_encoding(self, rolename: str | None = None) -> str:
60 | """
61 | Smart factory method to return the identifier of an existing or newly created account on this
62 | Node that contains characters that require percent-encoding when provided as resource in a WebFinger
63 | query. Different rolenames produce different results; the same rolename produces the same result.
64 |
65 | If the Node does not ever issue such identifiers, raise NotImplementedByNodeException
66 | """
67 | raise NotImplementedByNodeError(self, WebFingerServer.obtain_account_identifier_requiring_percent_encoding)
68 |
--------------------------------------------------------------------------------
/src/feditest/nodedrivers/imp/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | An in-process Node implementation for now.
3 | """
4 |
5 | import httpx
6 | from multidict import MultiDict
7 |
8 | from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver, HOSTNAME_PAR
9 | from feditest.protocols.web.diag import (
10 | HttpRequest,
11 | HttpRequestResponsePair,
12 | HttpResponse,
13 | WebDiagClient
14 | )
15 | from feditest.protocols.webfinger.abstract import AbstractWebFingerDiagClient
16 | from feditest.reporting import trace
17 | from feditest.testplan import TestPlanConstellationNode, TestPlanNodeParameter
18 | from feditest.utils import FEDITEST_VERSION
19 |
20 | _HEADERS = {
21 | "User-Agent": f"feditest/{ FEDITEST_VERSION }",
22 | "Origin": "https://test.example" # to trigger CORS headers in response
23 | }
24 |
25 | class Imp(AbstractWebFingerDiagClient):
26 | """
27 | In-process diagnostic WebFinger client.
28 | """
29 | # Python 3.12 @override
30 | def http(self, request: HttpRequest, follow_redirects: bool = True, verify=False) -> HttpRequestResponsePair:
31 | trace( f'Performing HTTP { request.method } on { request.parsed_uri.uri }')
32 |
33 | httpx_response = None
34 | # Do not follow redirects automatically, we need to know whether there are any
35 | with httpx.Client(verify=verify, follow_redirects=follow_redirects) as httpx_client:
36 | httpx_request = httpx.Request(request.method, request.parsed_uri.uri, headers=_HEADERS) # FIXME more arguments
37 | httpx_response = httpx_client.send(httpx_request)
38 |
39 | # FIXME: catch Tls exception and raise WebDiagClient.TlsError
40 |
41 | if httpx_response:
42 | response_headers : MultiDict = MultiDict()
43 | for key, value in httpx_response.headers.items():
44 | response_headers.add(key.lower(), value)
45 | ret = HttpRequestResponsePair(request, request, HttpResponse(httpx_response.status_code, response_headers, httpx_response.read()))
46 | trace( f'HTTP query returns { ret }')
47 | return ret
48 | raise WebDiagClient.HttpUnsuccessfulError(request)
49 |
50 |
51 | # Python 3.12 @override
52 | def add_cert_to_trust_store(self, root_cert: str) -> None:
53 | """
54 | On the Imp, we don't do this (for now?)
55 | """
56 | return
57 |
58 |
59 | # Python 3.12 @override
60 | def remove_cert_from_trust_store(self, root_cert: str) -> None:
61 | return
62 |
63 |
64 | class ImpInProcessNodeDriver(NodeDriver):
65 | """
66 | Knows how to instantiate an Imp.
67 | """
68 | # Python 3.12 @override
69 | @staticmethod
70 | def test_plan_node_parameters() -> list[TestPlanNodeParameter]:
71 | return []
72 |
73 |
74 | # Python 3.12 @override
75 | def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]:
76 | return (
77 | NodeConfiguration(
78 | self,
79 | 'Imp',
80 | FEDITEST_VERSION,
81 | test_plan_node.parameter(HOSTNAME_PAR)
82 | ),
83 | None
84 | )
85 |
86 |
87 | # Python 3.12 @override
88 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> Imp:
89 | return Imp(rolename, config, account_manager)
90 |
91 |
92 | # Python 3.12 @override
93 | def _unprovision_node(self, node: Node) -> None:
94 | pass
95 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_exceptions.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a tests that have errors (not test failures), i.e. tests that are buggy.
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
9 | from feditest.testrun import TestRun
10 | from feditest.testruncontroller import AutomaticTestRunController
11 | from feditest import test
12 |
13 |
14 | @pytest.fixture(scope="module", autouse=True)
15 | def init_node_drivers():
16 | """
17 | Cleanly define the NodeDrivers.
18 | """
19 | feditest.all_node_drivers = {}
20 | feditest.load_default_node_drivers()
21 |
22 |
23 | @pytest.fixture(scope="module", autouse=True)
24 | def init_tests():
25 | """
26 | Cleanly define some tests.
27 | """
28 | feditest.all_tests = {}
29 | feditest._registered_as_test = {}
30 | feditest._registered_as_test_step = {}
31 | feditest._loading_tests = True
32 |
33 | ##
34 | ## FediTest tests start here
35 | ##
36 |
37 | @test
38 | def assertion_error() -> None:
39 | """
40 | This test fails a standard Python assertion.
41 | """
42 | assert False
43 |
44 |
45 | @test
46 | def attribute_error() -> None:
47 | """
48 | This test always raises an AttributeError.
49 | """
50 | a = None
51 | return a.b
52 |
53 |
54 | @test
55 | def type_error() -> None:
56 | """
57 | This test always raises a TypeError.
58 | """
59 | a = None
60 | return a + 2
61 |
62 |
63 | @test
64 | def value_error() -> None:
65 | """
66 | This test always raises a ValueError.
67 | """
68 | raise ValueError('This test raises a ValueError.')
69 |
70 |
71 | ##
72 | ## FediTest tests end here
73 | ## (Don't forget the next two lines)
74 | ###
75 |
76 | feditest._loading_tests = False
77 | feditest._load_tests_pass2()
78 |
79 |
80 | @pytest.fixture(autouse=True)
81 | def test_plan_fixture() -> TestPlan:
82 | """
83 | The test plan tests all known tests.
84 | """
85 | constellation = TestPlanConstellation({}, 'No nodes needed')
86 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
87 | session = TestPlanSessionTemplate(tests, "Tests buggy tests")
88 | ret = TestPlan(session, [ constellation ])
89 | ret.properties_validate()
90 | return ret
91 |
92 |
93 | def test_run_testplan(test_plan_fixture: TestPlan):
94 | test_plan_fixture.check_can_be_executed()
95 |
96 | test_run = TestRun(test_plan_fixture)
97 | controller = AutomaticTestRunController(test_run)
98 | test_run.run(controller)
99 |
100 | transcript = test_run.transcribe()
101 | summary = transcript.build_summary()
102 |
103 | assert summary.n_total == 4
104 | assert summary.n_failed == 0
105 | assert summary.n_skipped == 0
106 | assert summary.n_errored == 4
107 | assert summary.n_passed == 0
108 |
109 | assert len(transcript.sessions) == 1
110 | assert len(transcript.sessions[0].run_tests) == 4
111 | assert transcript.sessions[0].run_tests[0].result.type == 'AssertionError'
112 | assert transcript.sessions[0].run_tests[1].result.type == 'AttributeError'
113 | assert transcript.sessions[0].run_tests[2].result.type == 'TypeError'
114 | assert transcript.sessions[0].run_tests[3].result.type == 'ValueError'
115 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | #
2 | # make venv
3 | # Create a python environment for your platform and install the required dependencies int
4 | # it. It will be in ./venv.linux.default if you are Linux
5 | #
6 | # make build
7 | # run pip install in your venv
8 | #
9 | # make lint
10 | # Run several linters on the code
11 | #
12 | # make test
13 | # Run unit tests
14 | #
15 | # NOTE: This does not add the venv to your $PATH. You have to do that yourself if you want that.
16 | #
17 |
18 | UNAME?=$(shell uname -s | tr [A-Z] [a-z])
19 | TAG?=default
20 | VENV?=venv.$(UNAME).$(TAG)
21 | PYTHON?=python3.11
22 | FEDITEST?=$(VENV)/bin/feditest -v
23 | DOMAIN?=--domain 1234.lan
24 |
25 |
26 | default : lint
27 |
28 | all : lint tests
29 |
30 | build : venv
31 | $(VENV)/bin/pip install .
32 |
33 | venv : $(VENV)
34 |
35 | $(VENV) :
36 | @which $(PYTHON) || ( echo 'No executable called "python". Append your python to the make command, like "make PYTHON=your-python"' && false )
37 | $(PYTHON) -mvenv $(VENV)
38 | $(VENV)/bin/pip install ruff mypy pylint
39 |
40 | lint : build
41 | $(VENV)/bin/ruff check
42 | MYPYPATH=src $(VENV)/bin/mypy --namespace-packages --explicit-package-bases --install-types --non-interactive src
43 | @# These options should be the same flags as in .pre-commit-config.yml, except that I can't get it to
44 | @# work there without the "--ignore-missing-imports" flag, while it does work without it here
45 |
46 | @# MYPYPATH is needed because apparently some type checking ignores the directory option given as command-line argument
47 | @# $(VENV)/bin/pylint src
48 |
49 | tests : tests.unit tests.smoke
50 |
51 | tests.unit :
52 | $(VENV)/bin/pytest -v
53 |
54 | tests.smoke : tests.smoke.webfinger tests.smoke.api tests.smoke.fediverse
55 |
56 | tests.smoke.webfinger :
57 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/webfinger.session.json --node client=tests.smoke/imp.node.json --node server=tests.smoke/wordpress.ubos.node.json $(DOMAIN)
58 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/webfinger.session.json --node client=tests.smoke/imp.node.json --node server=tests.smoke/mastodon.ubos.node.json $(DOMAIN)
59 |
60 | tests.smoke.api :
61 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --node server=tests.smoke/wordpress.ubos.node.json $(DOMAIN)
62 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --node server=tests.smoke/mastodon.ubos.node.json $(DOMAIN)
63 |
64 | tests.smoke.fediverse :
65 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --node leader_node=tests.smoke/mastodon.ubos.node.json --node follower_node=tests.smoke/mastodon.ubos.node.json $(DOMAIN)
66 | $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --node leader_node=tests.smoke/wordpress.ubos.node.json --node follower_node=tests.smoke/mastodon.ubos.node.json $(DOMAIN)
67 |
68 | release :
69 | @which $(PYTHON) || ( echo 'No executable called "python". Append your python to the make command, like "make PYTHON=your-python"' && false )
70 | [[ -d venv.release ]] && rm -rf venv.release || true
71 | [[ -d dist ]] && rm -rf dist || true
72 | $(PYTHON) -mvenv venv.release
73 | FEDITEST_RELEASE_VERSION=y venv.release/bin/pip install twine
74 | FEDITEST_RELEASE_VERSION=y venv.release/bin/pip install --upgrade build
75 | FEDITEST_RELEASE_VERSION=y venv.release/bin/python -m build
76 | @echo WARNING: YOU ARE NOT DONE YET
77 | @echo The actual push to pypi.org you need to do manually. Enter:
78 | @echo venv.release/bin/twine upload dist/*
79 |
80 | .PHONY: all default venv build lint tests tests.unit tests.smoke tests.smoke.webfinger tests.smoke.api tests.smoke.fediverse release
81 |
--------------------------------------------------------------------------------
/tests.unit/test_40_ubos_mastodon_accounts_from_testplan.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that Accounts and NonExistingAccounts are parsed correctly when given in a TestPlan that
3 | specifies a MastodonUbosNodeDriver
4 | """
5 |
6 | from typing import cast
7 |
8 | import pytest
9 |
10 | import feditest
11 | from feditest.nodedrivers.mastodon import (
12 | MastodonAccount,
13 | MastodonOAuthTokenAccount,
14 | MastodonUserPasswordAccount,
15 | EMAIL_ACCOUNT_FIELD,
16 | PASSWORD_ACCOUNT_FIELD,
17 | OAUTH_TOKEN_ACCOUNT_FIELD,
18 | ROLE_ACCOUNT_FIELD,
19 | ROLE_NON_EXISTING_ACCOUNT_FIELD,
20 | USERID_ACCOUNT_FIELD,
21 | USERID_NON_EXISTING_ACCOUNT_FIELD
22 | )
23 | from feditest.nodedrivers.mastodon.ubos import MastodonUbosNodeDriver
24 | from feditest.protocols.fediverse import FediverseNonExistingAccount
25 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate
26 |
27 |
28 | HOSTNAME = 'localhost'
29 | NODE1_ROLE = 'node1-role'
30 |
31 |
32 | @pytest.fixture(scope="module", autouse=True)
33 | def init():
34 | """ Clean init """
35 | feditest.all_tests = {}
36 | feditest._registered_as_test = {}
37 | feditest._registered_as_test_step = {}
38 | feditest._loading_tests = True
39 |
40 | feditest._loading_tests = False
41 | feditest._load_tests_pass2()
42 |
43 |
44 | @pytest.fixture(autouse=True)
45 | def the_test_plan() -> TestPlan:
46 | node_driver = MastodonUbosNodeDriver()
47 | parameters = None
48 | plan_accounts = [
49 | {
50 | ROLE_ACCOUNT_FIELD.name : 'role1',
51 | USERID_ACCOUNT_FIELD.name : 'foo',
52 | EMAIL_ACCOUNT_FIELD.name : 'foo@bar.com',
53 | PASSWORD_ACCOUNT_FIELD.name : 'verysecret'
54 | },
55 | {
56 | ROLE_ACCOUNT_FIELD.name : 'role2',
57 | USERID_ACCOUNT_FIELD.name : 'bar',
58 | OAUTH_TOKEN_ACCOUNT_FIELD.name : 'tokentokentoken'
59 | }
60 | ]
61 | plan_non_existing_accounts = [
62 | {
63 | ROLE_NON_EXISTING_ACCOUNT_FIELD.name : 'nonrole1',
64 | USERID_NON_EXISTING_ACCOUNT_FIELD.name : 'nouser'
65 | }
66 | ]
67 | node1 = TestPlanConstellationNode(node_driver, parameters, plan_accounts, plan_non_existing_accounts)
68 | constellation = TestPlanConstellation({ NODE1_ROLE : node1 })
69 | session = TestPlanSessionTemplate([])
70 | ret = TestPlan(session, [ constellation ])
71 | ret.properties_validate()
72 | return ret
73 |
74 |
75 | def test_parse(the_test_plan: TestPlan) -> None:
76 | """
77 | Tests parsing the TestPlan
78 | """
79 | node1 = the_test_plan.constellations[0].roles[NODE1_ROLE]
80 | node_driver = node1.nodedriver
81 |
82 | node_config, account_manager = node_driver.create_configuration_account_manager(NODE1_ROLE, node1)
83 |
84 | acc1 = cast(MastodonAccount | None, account_manager.get_account_by_role('role1'))
85 | assert acc1
86 | assert acc1.role == 'role1'
87 | assert acc1.userid == 'foo'
88 | assert isinstance(acc1, MastodonUserPasswordAccount)
89 | assert acc1._email == 'foo@bar.com'
90 | assert acc1._password == 'verysecret'
91 |
92 | acc2 = cast(MastodonAccount | None, account_manager.get_account_by_role('role2'))
93 | assert acc2
94 | assert acc2.role == 'role2'
95 | assert acc2.userid == 'bar'
96 | assert isinstance(acc2, MastodonOAuthTokenAccount)
97 | assert acc2._oauth_token == 'tokentokentoken'
98 |
99 | non_acc1 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('nonrole1'))
100 | assert non_acc1
101 | assert non_acc1.role == 'nonrole1'
102 | assert non_acc1.userid == 'nouser'
103 |
104 |
--------------------------------------------------------------------------------
/src/feditest/tests.py:
--------------------------------------------------------------------------------
1 | """
2 | Classes that represent tests.
3 | """
4 |
5 | from abc import ABC, abstractmethod
6 | from collections.abc import Callable
7 | from inspect import getfullargspec
8 | from typing import Any
9 |
10 |
11 | class Test(ABC):
12 | """
13 | Captures the notion of a Test, such as "see whether a follower is told about a new post".
14 | """
15 | def __init__(self, name: str, description: str | None, builtin: bool) -> None:
16 | self.name: str = name
17 | self.description: str | None = description
18 | self._builtin: bool = builtin
19 |
20 |
21 | def __str__(self):
22 | return self.name
23 |
24 |
25 | @abstractmethod
26 | def metadata(self) -> dict[str, Any]:
27 | ...
28 |
29 |
30 | @abstractmethod
31 | def needed_local_role_names(self) -> set[str]:
32 | """
33 | Determines the local names of the constellation roles this test needs. These may be mapped to
34 | constellation roles in the test definition.
35 | """
36 | ...
37 |
38 |
39 | @property
40 | def builtin(self):
41 | """
42 | If true, do not add this test to a test session when the session is created by collecting tests.
43 | """
44 | return self._builtin
45 |
46 |
47 | class TestFromTestFunction(Test):
48 | """
49 | A test that is defined as a single function.
50 | """
51 | def __init__(self, name: str, description: str | None, test_function: Callable[..., None], builtin: bool = False) -> None:
52 | super().__init__(name, description, builtin)
53 |
54 | self.test_function = test_function
55 |
56 |
57 | # Python 3.12 @override
58 | def metadata(self) -> dict[str, Any]:
59 | return {
60 | 'Name:' : self.name,
61 | 'Description:' : self.description
62 | }
63 |
64 |
65 | # Python 3.12 @override
66 | def needed_local_role_names(self) -> set[str]:
67 | ret = {}
68 | function_spec = getfullargspec(self.test_function)
69 | for arg in function_spec.args:
70 | ret[arg] = 1
71 | return set(ret)
72 |
73 |
74 |
75 | class TestStepInTestClass:
76 | """
77 | A step in a TestFromTestClass. TestSteps for the same Test are all declared with @step in the same class,
78 | and will be executed in sequence unless specified otherwise.
79 | """
80 | def __init__(self, name: str, description: str | None, test: 'TestFromTestClass', test_step_function: Callable[[Any],None]) -> None:
81 | self.name: str = name
82 | self.description: str | None = description
83 | self.test = test
84 | self.test_step_function: Callable[[Any], None] = test_step_function
85 |
86 |
87 | def __str__(self):
88 | return self.name
89 |
90 |
91 | class TestFromTestClass(Test):
92 | def __init__(self, name: str, description: str | None, clazz: type, builtin: bool = False) -> None:
93 | super().__init__(name, description, builtin)
94 |
95 | self.clazz = clazz
96 | self.steps : list[TestStepInTestClass] = []
97 |
98 |
99 | # Python 3.12 @override
100 | def metadata(self) -> dict[str, Any]:
101 | return {
102 | 'Name:' : self.name,
103 | 'Description:' : self.description,
104 | 'Steps:' : len(self.steps)
105 | }
106 |
107 |
108 | # Python 3.12 @override
109 | def needed_local_role_names(self) -> set[str]:
110 | """
111 | Determines the names of the constellation roles this test step needs.
112 | It determines that by creating the union of the parameter names of all the TestSteps in the Test
113 | """
114 | ret = {}
115 | function_spec = getfullargspec(self.clazz.__init__) # type: ignore [misc]
116 | for arg in function_spec.args[1:]: # first is self
117 | ret[arg] = 1
118 | return set(ret)
119 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/info.py:
--------------------------------------------------------------------------------
1 | """
2 | Provide information on a variety of objects
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 | from typing import Any
7 |
8 | import feditest
9 | import feditest.cli
10 | from feditest.utils import format_name_value_string
11 | from feditest.reporting import warning
12 |
13 |
14 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
15 | """
16 | Run this command.
17 | """
18 | if len(remaining):
19 | parser.print_help()
20 | return 0
21 |
22 | feditest.load_default_tests()
23 | feditest.load_tests_from(args.testsdir)
24 |
25 | feditest.load_default_node_drivers()
26 | if args.nodedriversdir:
27 | feditest.load_node_drivers_from(args.nodedriversdir)
28 |
29 | if args.test:
30 | return run_info_test(args.test)
31 |
32 | if args.nodedriver:
33 | return run_info_node_driver(args.nodedriver)
34 |
35 | parser.print_help()
36 | return 0
37 |
38 |
39 | def run_info_test(name: str) -> int:
40 | """
41 | Provide information on a test
42 | """
43 | test = feditest.all_tests.get(name)
44 | if test:
45 | test_metadata = test.metadata()
46 | needed_role_names = test.needed_local_role_names()
47 | if needed_role_names:
48 | test_metadata['Needed roles:'] = sorted(needed_role_names)
49 |
50 | print(format_name_value_string(test_metadata), end='')
51 | return 0
52 |
53 | warning( 'Test not found:', name)
54 | return 1
55 |
56 |
57 | def run_info_node_driver(name: str) -> int:
58 | """
59 | Provide information on a node driver
60 | """
61 | node_driver_class = feditest.all_node_drivers.get(name)
62 | if node_driver_class:
63 | node_driver_metadata : dict[str, Any] = {
64 | 'Node driver name:' : node_driver_class.__qualname__,
65 | 'Description:' : node_driver_class.__doc__,
66 | }
67 | pars = node_driver_class.test_plan_node_parameters()
68 | if pars:
69 | node_driver_metadata_pars = {}
70 | for par in pars:
71 | node_driver_metadata_pars[par.name] = par.description
72 | node_driver_metadata['Parameters:'] = node_driver_metadata_pars
73 |
74 | account_fields = node_driver_class.test_plan_node_account_fields()
75 | if account_fields:
76 | node_driver_metadata_fields = {}
77 | for field in account_fields:
78 | node_driver_metadata_fields[field.name] = field.description
79 | node_driver_metadata['Account fields:'] = node_driver_metadata_fields
80 |
81 | non_existing_account_fields = node_driver_class.test_plan_node_non_existing_account_fields()
82 | if non_existing_account_fields:
83 | node_driver_metadata_non_fields = {}
84 | for field in non_existing_account_fields:
85 | node_driver_metadata_non_fields[field.name] = field.description
86 | node_driver_metadata['Non-existing Account fields:'] = node_driver_metadata_non_fields
87 |
88 | print(format_name_value_string(node_driver_metadata), end='')
89 | return 0
90 |
91 | warning( 'Node driver not found:', name)
92 | return 1
93 |
94 |
95 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
96 | """
97 | Add command-line options for this sub-command
98 | parent_parser: the parent argparse parser
99 | cmd_name: name of this command
100 | """
101 | parser = parent_parser.add_parser( cmd_name, help='Provide information on a variety of objects')
102 | parser.add_argument('--testsdir', action='append', help='Directory or directories where to find tests')
103 | parser.add_argument('--nodedriversdir', action='append', help='Directory or directories where to find extra drivers for nodes that can be tested')
104 | type_group = parser.add_mutually_exclusive_group(required=True)
105 | type_group.add_argument('--test', help='Provide information about a test.')
106 | type_group.add_argument('--nodedriver', help='Provide information about a driver for a node to be tested.')
107 |
108 | return parser
109 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/templates/testplantranscript_default/partials/shared/summary.jinja2:
--------------------------------------------------------------------------------
1 | {% if summary.n_total %}
2 |
3 |
4 |
5 | | Status |
6 | Count |
7 |
8 |
9 |
10 |
11 | | Passed |
12 | {%- if summary.n_passed != 0 %}
13 |
14 | {{ summary.n_passed }} ({{ '%.1f' % ( summary.n_passed * 100.0 / summary.n_total ) }}%)
15 | |
16 | {%- else %}
17 |
18 | 0
19 | |
20 | {%- endif %}
21 |
22 |
23 | | Failed |
24 |
25 |
26 |
27 |
28 | |
29 | Interoperability |
30 |
31 |
32 | | Compromised |
33 | Degraded |
34 | Unaffected |
35 | Unknown |
36 | Total |
37 |
38 |
39 |
40 |
41 | | Conformance |
42 | Must |
43 | {%- for interop_level in feditest.InteropLevel %}
44 |
45 | {{ summary.count_failures_for(feditest.SpecLevel.MUST, interop_level) }}
46 | |
47 | {%- endfor %}
48 |
49 |
50 | {{ summary.count_failures_for(feditest.SpecLevel.MUST, None) }}
51 | ({{ '%.1f' % ( summary.count_failures_for(feditest.SpecLevel.MUST, None) * 100.0 / summary.n_total ) }}%)
52 |
53 | |
54 |
55 | {%- for spec_level in [ feditest.SpecLevel.SHOULD, feditest.SpecLevel.IMPLIED, feditest.SpecLevel.UNSPECIFIED ] %}
56 |
57 | | {{ spec_level.formatted_name | e }} |
58 | {%- for interop_level in feditest.InteropLevel %}
59 |
60 |
61 | {{ summary.count_failures_for(spec_level, interop_level) }}
62 |
63 | |
64 | {%- endfor %}
65 |
66 |
67 | {{ summary.count_failures_for(spec_level, None) }}
68 | ({{ '%.1f' % ( summary.count_failures_for(spec_level, None) * 100.0 / summary.n_total ) }}%)
69 |
70 | |
71 |
72 | {%- endfor %}
73 |
74 | | Total |
75 | {%- for interop_level in feditest.InteropLevel %}
76 |
77 |
78 | {{ summary.count_failures_for(None, interop_level) }}
79 | ({{ '%.1f' % ( summary.count_failures_for(None, interop_level) * 100.0 / summary.n_total ) }}%)
80 |
81 | |
82 | {%- endfor %}
83 |
84 |
85 | {{ summary.n_failed }}
86 | ({{ '%.1f' % ( summary.n_failed * 100.0 / summary.n_total ) }}%)
87 |
88 | |
89 |
90 |
91 |
92 | |
93 |
94 |
95 | | Skipped |
96 | {%- if summary.n_skipped != 0 %}
97 |
98 |
99 | {{ summary.n_skipped }}
100 | ({{ '%.1f' % ( summary.n_skipped * 100.0 / summary.n_total ) }}%)
101 |
102 | |
103 | {%- else %}
104 |
105 | 0
106 | |
107 | {%- endif %}
108 |
109 |
110 | | Errors |
111 | {%- if summary.n_errored != 0 %}
112 |
113 |
114 | {{ summary.n_errored }}
115 | ({{ '%.1f' % ( summary.n_errored * 100.0 / summary.n_total ) }}%)
116 |
117 | |
118 | {%- else %}
119 |
120 | 0
121 | |
122 | {%- endif %}
123 |
124 |
125 |
126 |
127 | | Total |
128 |
129 | {{ summary.n_total }}
130 | |
131 |
132 |
133 |
134 | {%- else %}
135 | No tests were run.
136 | {%- endif %}
--------------------------------------------------------------------------------
/tests.unit/test_50_transcript.py:
--------------------------------------------------------------------------------
1 | #
2 | # Test the transcript has all the expected values
3 | #
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import nodedriver, test
9 | from feditest.utils import FEDITEST_VERSION
10 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate, TestPlanTestSpec
11 | from feditest.testrun import TestRun
12 | from feditest.testruncontroller import AutomaticTestRunController
13 | from feditest.testruntranscript import TestRunResultTranscript
14 |
15 | from dummy import DummyNodeDriver
16 |
17 | APP_NAMES = [
18 | 'APP_0',
19 | 'APP_1',
20 | 'APP_2',
21 | 'APP_3'
22 | ]
23 | driver_names = []
24 |
25 | @pytest.fixture(scope="module", autouse=True)
26 | def init_node_drivers():
27 | global driver_names
28 |
29 | """ Keep these isolated to this module """
30 | feditest.all_node_drivers = {}
31 | feditest._loading_node_drivers = True
32 |
33 | @nodedriver
34 | class NodeDriver1(DummyNodeDriver):
35 | pass
36 |
37 | @nodedriver
38 | class NodeDriver2(DummyNodeDriver):
39 | pass
40 |
41 | feditest._loading_node_drivers = False
42 |
43 | driver_names = list(feditest.all_node_drivers.keys())
44 |
45 |
46 | @pytest.fixture(scope="module", autouse=True)
47 | def init_tests():
48 | """
49 | Cleanly define some tests.
50 | """
51 | feditest.all_tests = {}
52 | feditest._registered_as_test = {}
53 | feditest._registered_as_test_step = {}
54 | feditest._loading_tests = True
55 |
56 | ##
57 | ## FediTest tests start here
58 | ##
59 |
60 | @test
61 | def passes() -> None:
62 | """
63 | This test always passes.
64 | """
65 | return
66 |
67 | ##
68 | ## FediTest tests end here
69 | ## (Don't forget the next two lines)
70 | ##
71 |
72 | feditest._loading_tests = False
73 | feditest._load_tests_pass2()
74 |
75 |
76 | @pytest.fixture(autouse=True)
77 | def test_plan_fixture() -> TestPlan:
78 | """
79 | The test plan tests all known tests.
80 | """
81 | constellations = [
82 | TestPlanConstellation(
83 | {
84 | 'node1a': TestPlanConstellationNode(
85 | driver_names[0],
86 | {
87 | 'app' : APP_NAMES[0]
88 | }
89 | ),
90 | 'node2a': TestPlanConstellationNode(
91 | driver_names[1],
92 | {
93 | 'app' : APP_NAMES[1]
94 | }
95 | )
96 | },
97 | 'constellation-1'),
98 | TestPlanConstellation(
99 | {
100 | 'node1b': TestPlanConstellationNode(
101 | driver_names[0],
102 | {
103 | 'app' : APP_NAMES[2]
104 | }
105 | ),
106 | 'node2b': TestPlanConstellationNode(
107 | driver_names[1],
108 | {
109 | 'app' : APP_NAMES[3]
110 | }
111 | )
112 | },
113 | 'constellation-2')
114 | ]
115 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
116 | session = TestPlanSessionTemplate(tests, "Test a test that passes")
117 | ret = TestPlan(session, constellations)
118 | ret.properties_validate()
119 | # ret.print()
120 | return ret
121 |
122 |
123 | @pytest.fixture
124 | def transcript(test_plan_fixture: TestPlan) -> TestRunResultTranscript:
125 | test_plan_fixture.check_can_be_executed()
126 |
127 | test_run = TestRun(test_plan_fixture)
128 | controller = AutomaticTestRunController(test_run)
129 | test_run.run(controller)
130 |
131 | ret = test_run.transcribe()
132 | return ret
133 |
134 |
135 | def test_transcript(transcript: TestRunResultTranscript):
136 | assert transcript.plan
137 | assert transcript.id
138 | assert transcript.started
139 | assert transcript.ended
140 |
141 | assert len(transcript.sessions) == 2
142 | assert len(transcript.test_meta) == 1
143 | assert transcript.result is None
144 | assert transcript.type == 'feditest-testrun-transcript'
145 | assert transcript.feditest_version == FEDITEST_VERSION
146 |
147 | for i in range(0, 1):
148 | assert transcript.sessions[i].started
149 | assert transcript.sessions[i].ended
150 | assert len(transcript.sessions[i].run_tests) == 1
151 | assert transcript.sessions[i].run_tests[0].started
152 | assert transcript.sessions[i].run_tests[0].ended
153 | assert transcript.sessions[i].run_tests[0].result is None
154 |
155 |
--------------------------------------------------------------------------------
/tests.unit/test_40_static_account_manager_fallback_fediverse_accounts.py:
--------------------------------------------------------------------------------
1 | """
2 | Test the StaticAccountManager with the FediverseAccounts and FediverseNonExistingAccounts defined
3 | in the fallback Fediverse implementation.
4 | """
5 |
6 | from typing import cast
7 |
8 | import pytest
9 |
10 | import feditest
11 | from feditest.nodedrivers import StaticAccountManager
12 | from feditest.protocols.fediverse import (
13 | FediverseAccount,
14 | FediverseNonExistingAccount
15 | )
16 |
17 | @pytest.fixture(scope="module", autouse=True)
18 | def init():
19 | """ Clean init """
20 | feditest.all_tests = {}
21 | feditest._registered_as_test = {}
22 | feditest._registered_as_test_step = {}
23 | feditest._loading_tests = True
24 |
25 | feditest._loading_tests = False
26 | feditest._load_tests_pass2()
27 |
28 |
29 | @pytest.fixture(autouse=True)
30 | def account_manager() -> StaticAccountManager:
31 |
32 | initial_accounts : list[FediverseAccount] = [
33 | FediverseAccount(None, 'user-0-unallocated'),
34 | FediverseAccount('role1', 'user-1-role1'),
35 | FediverseAccount(None, 'user-2-unallocated'),
36 | FediverseAccount('role3', 'user-3-role3'),
37 | ]
38 | initial_non_existing_accounts : list[FediverseNonExistingAccount] = [
39 | FediverseNonExistingAccount(None, 'nonuser-0-unallocated'),
40 | FediverseNonExistingAccount('role1', 'nonuser-1-role1'),
41 | FediverseNonExistingAccount(None, 'nonuser-2-unallocated'),
42 | FediverseNonExistingAccount('role3', 'nonuser-3-role3'),
43 | ]
44 | ret = StaticAccountManager(initial_accounts, initial_non_existing_accounts)
45 | return ret
46 |
47 |
48 | def test_initial_accounts(account_manager: StaticAccountManager) -> None:
49 | """
50 | Test that AccountManager has sorted the provided Accounts into the right buckets.
51 | """
52 | acc1 = cast(FediverseAccount | None, account_manager.get_account_by_role('role1'))
53 | assert acc1
54 | assert acc1.role == 'role1'
55 | assert acc1.userid == 'user-1-role1'
56 |
57 | acc3 = cast(FediverseAccount | None, account_manager.get_account_by_role('role3'))
58 | assert acc3
59 | assert acc3.role == 'role3'
60 | assert acc3.userid == 'user-3-role3'
61 |
62 |
63 | def test_initial_non_existing_accounts(account_manager: StaticAccountManager) -> None:
64 | """
65 | Test that AccountManager has sorted the provided NonExistingAccounts into the right buckets.
66 | """
67 | acc1 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('role1'))
68 | assert acc1
69 | assert acc1.role == 'role1'
70 | assert acc1.userid == 'nonuser-1-role1'
71 |
72 | acc3 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('role3'))
73 | assert acc3
74 | assert acc3.role == 'role3'
75 | assert acc3.userid == 'nonuser-3-role3'
76 |
77 |
78 | def test_allocates_accounts_correctly(account_manager: StaticAccountManager) -> None:
79 | """
80 | Test that the right accounts are returned given the assigned and non-assigned roles.
81 | """
82 | # Do things a little out of order
83 | acc2 = cast(FediverseAccount | None, account_manager.get_account_by_role('role2'))
84 | assert acc2 is None
85 |
86 | acc0 = cast(FediverseAccount | None, account_manager.get_account_by_role('role0'))
87 | assert acc0 is None
88 |
89 | acc0 = cast(FediverseAccount | None, account_manager.obtain_account_by_role('role0'))
90 | assert acc0
91 | assert acc0.role is None
92 | assert acc0.userid == 'user-0-unallocated'
93 |
94 | acc2 = cast(FediverseAccount | None, account_manager.get_account_by_role('role2'))
95 | assert acc2 is None
96 |
97 | acc2 = cast(FediverseAccount | None, account_manager.obtain_account_by_role('role2'))
98 | assert acc2
99 | assert acc2.role is None
100 | assert acc2.userid == 'user-2-unallocated'
101 |
102 |
103 | def test_allocates_non_existing_accountscorrectly(account_manager: StaticAccountManager) -> None:
104 | """
105 | Test that the right non-existing accounts are returned given the assigned and non-assigned roles.
106 | """
107 | # Do things a little out of order
108 | acc2 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('role2'))
109 | assert acc2 is None
110 |
111 | acc0 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('role0'))
112 | assert acc0 is None
113 |
114 | acc0 = cast(FediverseNonExistingAccount | None, account_manager.obtain_non_existing_account_by_role('role0'))
115 | assert acc0
116 | assert acc0.role is None
117 | assert acc0.userid == 'nonuser-0-unallocated'
118 |
119 | acc2 = cast(FediverseNonExistingAccount | None, account_manager.get_non_existing_account_by_role('role2'))
120 | assert acc2 is None
121 |
122 | acc2 = cast(FediverseNonExistingAccount | None, account_manager.obtain_non_existing_account_by_role('role2'))
123 | assert acc2
124 | assert acc2.role is None
125 | assert acc2.userid == 'nonuser-2-unallocated'
126 |
--------------------------------------------------------------------------------
/src/feditest/reporting.py:
--------------------------------------------------------------------------------
1 | """
2 | Reporting functionality
3 | """
4 |
5 | import logging
6 | import logging.config
7 | import sys
8 | import traceback
9 |
10 | logging.config.dictConfig({
11 | 'version' : 1,
12 | 'disable_existing_loggers' : False,
13 | 'formatters' : {
14 | 'standard' : {
15 | 'format' : '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
16 | 'datefmt' : '%Y-%m-%dT%H:%M:%SZ'
17 | },
18 | },
19 | 'handlers' : {
20 | 'default' : {
21 | 'level' : 'DEBUG',
22 | 'formatter' : 'standard',
23 | 'class' : 'logging.StreamHandler',
24 | 'stream' : 'ext://sys.stderr'
25 | }
26 | },
27 | 'loggers' : {
28 | '' : { # root logger -- set level to most output that can happen
29 | 'handlers' : [ 'default' ],
30 | 'level' : 'WARNING',
31 | 'propagate' : True
32 | }
33 | }
34 | })
35 | LOG = logging.getLogger( 'feditest' )
36 |
37 | def set_reporting_level(n_verbose_flags: int) :
38 | if n_verbose_flags == 1:
39 | LOG.setLevel(logging.INFO)
40 | elif n_verbose_flags >= 2:
41 | LOG.setLevel(logging.DEBUG)
42 |
43 | def trace(*args):
44 | """
45 | Emit a trace message.
46 |
47 | args: the message or message components
48 | """
49 | if LOG.isEnabledFor(logging.DEBUG):
50 | LOG.debug(_construct_msg(True, False, args))
51 |
52 |
53 | def is_trace_active() :
54 | """
55 | Is trace logging on?
56 |
57 | return: True or False
58 | """
59 | return LOG.isEnabledFor(logging.DEBUG)
60 |
61 |
62 | def info(*args):
63 | """
64 | Emit an info message.
65 |
66 | args: msg: the message or message components
67 | """
68 | if LOG.isEnabledFor(logging.INFO):
69 | LOG.info(_construct_msg(False, False, args))
70 |
71 |
72 | def is_info_active():
73 | """
74 | Is info logging on?
75 |
76 | return: True or False
77 | """
78 | return LOG.isEnabledFor(logging.INFO)
79 |
80 |
81 | def warning(*args):
82 | """
83 | Emit a warning message.
84 |
85 | args: the message or message components
86 | """
87 |
88 | if LOG.isEnabledFor(logging.WARNING):
89 | LOG.warning(_construct_msg(False, LOG.isEnabledFor(logging.DEBUG), args))
90 |
91 |
92 | def is_warning_active():
93 | """
94 | Is warning logging on?
95 |
96 | return: True or False
97 | """
98 | return LOG.isEnabledFor(logging.WARNING)
99 |
100 |
101 | def error(*args):
102 | """
103 | Emit an error message.
104 |
105 | args: the message or message components
106 | """
107 | if LOG.isEnabledFor(logging.ERROR):
108 | LOG.error(_construct_msg(False, LOG.isEnabledFor(logging.DEBUG), args))
109 |
110 |
111 | def is_error_active():
112 | """
113 | Is error logging on?
114 |
115 | return: True or False
116 | """
117 | return LOG.isEnabledFor(logging.ERROR)
118 |
119 |
120 | def fatal(*args):
121 | """
122 | Emit a fatal error message and exit with code 1.
123 |
124 | args: the message or message components
125 | """
126 |
127 | if args:
128 | if LOG.isEnabledFor(logging.CRITICAL):
129 | LOG.critical(_construct_msg(False, LOG.isEnabledFor(logging.DEBUG), args))
130 |
131 | raise SystemExit(255) # Don't call exit() because that will close stdin
132 |
133 |
134 | def is_fatal_active():
135 | """
136 | Is fatal logging on?
137 |
138 | return: True or False
139 | """
140 | return LOG.isEnabledFor(logging.CRITICAL)
141 |
142 |
143 | def _construct_msg(with_loc, with_tb, *args):
144 | """
145 | Construct a message from these arguments.
146 |
147 | with_loc: construct message with location info
148 | with_tb: construct message with traceback if an exception is the last argument
149 | args: the message or message components
150 | return: string message
151 | """
152 | if with_loc:
153 | frame = sys._getframe(2) # pylint: disable=protected-access
154 | loc = frame.f_code.co_filename
155 | loc += '#'
156 | loc += str(frame.f_lineno)
157 | loc += ' '
158 | loc += frame.f_code.co_name
159 | loc += ':'
160 | ret = loc
161 | else:
162 | ret = ''
163 |
164 |
165 | def m(a):
166 | """
167 | Formats provided arguments into something suitable for log messages.
168 |
169 | a: the argument
170 | return: string for the log
171 | """
172 | if a is None:
173 | return ''
174 | if callable(a):
175 | return str(a)
176 | if isinstance(a, OSError):
177 | return type(a).__name__ + ' ' + str(a)
178 | return a
179 |
180 | args2 = map(m, *args)
181 | ret += ' '.join(map(str, args2))
182 |
183 | if with_tb and len(*args) > 0:
184 | *_, last = iter(*args)
185 | if isinstance(last, Exception):
186 | ret += ''.join(traceback.format_exception(type(last), last, last.__traceback__))
187 |
188 | return ret
189 |
--------------------------------------------------------------------------------
/src/feditest/cli/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions used by the CLI commands.
3 | """
4 |
5 | from argparse import ArgumentError, Namespace
6 | import re
7 |
8 | from msgspec import ValidationError
9 |
10 | import feditest
11 | from feditest.tests import Test
12 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate, TestPlanTestSpec
13 |
14 | def create_plan_from_testplan(args: Namespace) -> TestPlan:
15 | if args.constellation:
16 | raise ArgumentError(None, '--testplan already defines --constellation. Do not provide both.')
17 | if args.session:
18 | raise ArgumentError(None, '--testplan already defines --session. Do not provide both.')
19 | if args.node:
20 | raise ArgumentError(None, '--testplan already defines --node via the contained constellation. Do not provide both.')
21 | if args.test:
22 | raise ArgumentError(None, '--testplan already defines --test via the contained session. Do not provide both.')
23 | plan = TestPlan.load(args.testplan)
24 | return plan
25 |
26 |
27 | def create_plan_from_session_and_constellations(args: Namespace) -> TestPlan | None:
28 | session = create_session(args)
29 | constellations = create_constellations(args)
30 |
31 | plan = TestPlan(session, constellations, args.name)
32 | plan.properties_validate()
33 | plan.simplify()
34 | return plan
35 |
36 |
37 | def create_session(args: Namespace) -> TestPlanSessionTemplate:
38 | if args.session:
39 | session_template = create_session_from_files(args)
40 | else:
41 | session_template = create_session_template_from_tests(args)
42 | return session_template
43 |
44 |
45 | def create_session_from_files(args: Namespace) -> TestPlanSessionTemplate:
46 | if args.filter_regex:
47 | raise ArgumentError(None, '--session already defines the tests, do not provide --filter-regex')
48 | if args.test:
49 | raise ArgumentError(None, '--session already defines --test. Do not provide both.')
50 | return TestPlanSessionTemplate.load(args.session)
51 |
52 |
53 | def create_session_template_from_tests(args: Namespace) -> TestPlanSessionTemplate:
54 | test_plan_specs : list[TestPlanTestSpec]= []
55 | tests : list[Test]= []
56 |
57 | if args.test:
58 | if args.filter_regex:
59 | raise ArgumentError(None, '--filter-regex already defines --test. Do not provide both.')
60 | for name in args.test:
61 | test = feditest.all_tests.get(name)
62 | if test is None:
63 | raise ArgumentError(None, f'Cannot find test: "{ name }".')
64 | tests.append(test)
65 |
66 | elif args.filter_regex:
67 | pattern = re.compile(args.filter_regex)
68 | for name in sorted(feditest.all_tests.keys()):
69 | if pattern.match(name):
70 | test = feditest.all_tests.get(name)
71 | if test is None: # make linter happy
72 | continue
73 | if test.builtin:
74 | continue
75 | tests.append(test)
76 |
77 | else:
78 | for name in sorted(feditest.all_tests.keys()):
79 | test = feditest.all_tests.get(name)
80 | if test is None: # make linter happy
81 | continue
82 | if test.builtin:
83 | continue
84 | tests.append(test)
85 |
86 | for test in tests:
87 | name = test.name
88 | test_plan_spec = TestPlanTestSpec(name)
89 | test_plan_specs.append(test_plan_spec)
90 |
91 | session = TestPlanSessionTemplate(test_plan_specs, args.name)
92 | return session
93 |
94 |
95 | def create_constellations(args: Namespace) -> list[TestPlanConstellation]:
96 | if args.constellation:
97 | constellations = create_constellations_from_files(args)
98 | else:
99 | constellation = create_constellation_from_nodes(args)
100 | constellations = [ constellation ]
101 | return constellations
102 |
103 |
104 | def create_constellations_from_files(args: Namespace) -> list[TestPlanConstellation]:
105 | if args.node:
106 | raise ArgumentError(None, '--constellation already defines --node. Do not provide both.')
107 |
108 | constellations = []
109 | for constellation_file in args.constellation:
110 | try:
111 | constellations.append(TestPlanConstellation.load(constellation_file))
112 | except ValidationError as e:
113 | raise ArgumentError(None, f'Constellation file { constellation_file }: { e }')
114 | return constellations
115 |
116 |
117 | def create_constellation_from_nodes(args: Namespace) -> TestPlanConstellation:
118 | # Don't check for empty nodes: we need that for testing feditest
119 | roles : dict[str, TestPlanConstellationNode | None] = {}
120 | if args.node:
121 | for nodepair in args.node:
122 | if '=' not in nodepair:
123 | raise ArgumentError(None, 'Syntax error in argument: need --node =')
124 | rolename, nodefile = nodepair.split('=', 1)
125 | if not rolename:
126 | raise ArgumentError(None, f'Rolename component of --node must not be empty: "{ nodepair }".')
127 | if rolename in roles:
128 | raise ArgumentError(None, f'Role is already taken: "{ rolename }".')
129 | if not nodefile:
130 | raise ArgumentError(None, f'Filename component must not be empty: "{ nodepair }".')
131 | node = TestPlanConstellationNode.load(nodefile)
132 | roles[rolename] = node
133 |
134 | constellation = TestPlanConstellation(roles)
135 | return constellation
136 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_testplan_sandbox.py:
--------------------------------------------------------------------------------
1 | """
2 | Run some sandbox protocol tests.
3 | """
4 |
5 | from typing import List
6 |
7 | from hamcrest import equal_to, close_to
8 | import pytest
9 |
10 | import feditest
11 | from feditest import assert_that, step, test, SpecLevel
12 | from feditest.protocols.sandbox import SandboxLogEvent, SandboxMultClient, SandboxMultServer
13 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate, TestPlanTestSpec
14 | from feditest.testrun import TestRun
15 | from feditest.testruncontroller import AutomaticTestRunController
16 |
17 |
18 | @pytest.fixture(scope="module", autouse=True)
19 | def init_node_drivers():
20 | """
21 | Cleanly define the NodeDrivers.
22 | """
23 | feditest.all_node_drivers = {}
24 | feditest.load_default_node_drivers()
25 |
26 |
27 | @pytest.fixture(scope="module", autouse=True)
28 | def init_tests():
29 | """
30 | Cleanly define some tests.
31 | These tests are the same as those run in "sandbox-all-clientA-vs-server1.json" from the feditest-tests-sandbox repo.
32 | """
33 | feditest.all_tests = {}
34 | feditest._registered_as_test = {}
35 | feditest._registered_as_test_step = {}
36 | feditest._loading_tests = True
37 |
38 | ##
39 | ## FediTest tests start here
40 | ##
41 |
42 | @test
43 | class ExampleTest1:
44 | """
45 | Tests the sandbox toy protocol using a FediTest test class.
46 | """
47 | def __init__(self,
48 | client: SandboxMultClient,
49 | server: SandboxMultServer
50 | ) -> None:
51 | self.client = client
52 | self.server = server
53 |
54 | # We put some test data into the test class instance to demonstrate how it can be passed
55 | # along several test steps, even if one or more of them fail (with a soft assertion error)
56 |
57 | self.a : float = 2.1
58 | self.b : int = 7
59 |
60 |
61 | @step
62 | def step1(self):
63 | self.server.start_logging()
64 |
65 | self.c : float = self.client.cause_mult(self.server, self.a, self.b)
66 |
67 | assert_that(self.c, close_to(15.0, 0.5))
68 |
69 | log: List[SandboxLogEvent] = self.server.get_and_clear_log()
70 |
71 | assert_that(len(log), equal_to(1))
72 | assert_that(log[0].a, equal_to(self.a))
73 | assert_that(log[0].b, equal_to(self.b))
74 | assert_that(log[0].c, equal_to(self.c))
75 |
76 |
77 | @step
78 | def step2(self):
79 |
80 | c_squared = self.client.cause_mult(self.server, self.c, self.c)
81 |
82 | assert_that(c_squared, close_to(self.c * self.c, 0.001), spec_level=SpecLevel.SHOULD)
83 | assert_that(c_squared, close_to(self.c * self.c, 0.5))
84 |
85 |
86 | @test
87 | def example_test1(
88 | client: SandboxMultClient,
89 | server: SandboxMultServer
90 | ) -> None:
91 | """
92 | Tests the sandbox toy protocol using a FediTest test function with hard asserts.
93 | """
94 | a : float = 2
95 | b : int = 7
96 |
97 | server.start_logging()
98 |
99 | c : float = client.cause_mult(server, a, b)
100 |
101 | assert_that(c, equal_to(14.0))
102 |
103 | log: List[SandboxLogEvent] = server.get_and_clear_log()
104 |
105 | assert_that(len(log), equal_to(1))
106 | assert_that(log[0].a, equal_to(a))
107 | assert_that(log[0].b, equal_to(b))
108 | assert_that(log[0].c, equal_to(c))
109 |
110 |
111 | @test
112 | def example_test2(
113 | client: SandboxMultClient,
114 | server: SandboxMultServer
115 | ) -> None:
116 | """
117 | Tests the sandbox toy protocol using a FedTest test function with hard asserts.
118 | """
119 | a : float = 2.1
120 | b : int = 7
121 |
122 | c : float = client.cause_mult(server, a, b)
123 |
124 | assert_that(c, close_to(14.7, 0.01), spec_level=SpecLevel.SHOULD)
125 |
126 |
127 | @test
128 | def example_test3(
129 | client: SandboxMultClient,
130 | server: SandboxMultServer
131 | ) -> None:
132 | """
133 | Tests the sandbox toy protocol using a FedTest test function.
134 | """
135 | a : int = -7
136 | b : int = 8
137 |
138 | c = client.cause_mult(server, a, b)
139 |
140 | assert_that(c, equal_to(a * b))
141 |
142 | ##
143 | ## FediTest tests end here
144 | ## (Don't forget the next two lines)
145 | ##
146 |
147 | feditest._loading_tests = False
148 | feditest._load_tests_pass2()
149 |
150 |
151 | @pytest.fixture(autouse=True)
152 | def test_plan_fixture() -> TestPlan:
153 | """
154 | The test plan tests all known tests.
155 | """
156 | roles = {
157 | 'client' : TestPlanConstellationNode('SandboxMultClientDriver_ImplementationA'),
158 | 'server' : TestPlanConstellationNode('SandboxMultServerDriver_Implementation1'),
159 | }
160 | constellation = TestPlanConstellation(roles, 'clientA vs server1')
161 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
162 | session = TestPlanSessionTemplate(tests, "clientA vs server")
163 | ret = TestPlan(session, [ constellation ], "All sandbox tests running clientA against server1")
164 | ret.properties_validate()
165 | return ret
166 |
167 |
168 | def test_run_testplan(test_plan_fixture: TestPlan):
169 | test_plan_fixture.check_can_be_executed()
170 |
171 | test_run = TestRun(test_plan_fixture)
172 | controller = AutomaticTestRunController(test_run)
173 | test_run.run(controller)
174 |
175 | transcript = test_run.transcribe()
176 |
177 | assert transcript.build_summary().n_failed == 0
178 |
179 |
--------------------------------------------------------------------------------
/src/feditest/nodedrivers/sandbox/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | """
3 |
4 | from typing import List
5 |
6 | from feditest.nodedrivers import AccountManager, NodeConfiguration, NodeDriver, HOSTNAME_PAR
7 | from feditest.protocols.sandbox import SandboxLogEvent, SandboxMultClient, SandboxMultServer
8 | from feditest.testplan import TestPlanConstellationNode, TestPlanNodeParameter
9 | from feditest.utils import FEDITEST_VERSION
10 |
11 |
12 | class SandboxMultClient_ImplementationA(SandboxMultClient):
13 | """
14 | A client implementation in the Sandbox protocol that can be tested. It's trivially simple.
15 | """
16 | def cause_mult(self, server: SandboxMultServer, a: float, b: float) -> float:
17 | c = server.mult(a, b)
18 | return c
19 |
20 |
21 | class SandboxMultClientDriver_ImplementationA(NodeDriver):
22 | """
23 | Driver for the client implementation, so the client can be provisioned and unprovisioned for
24 | test sessions.
25 | """
26 | # Python 3.12 @override
27 | @staticmethod
28 | def test_plan_node_parameters() -> list[TestPlanNodeParameter]:
29 | return [ HOSTNAME_PAR ]
30 |
31 |
32 | # Python 3.12 @override
33 | def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]:
34 | return (
35 | NodeConfiguration(
36 | self,
37 | 'SandboxMultClient_ImplementationA',
38 | FEDITEST_VERSION,
39 | test_plan_node.parameter(HOSTNAME_PAR)
40 | ),
41 | None
42 | )
43 |
44 |
45 | # Python 3.12 @override
46 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> SandboxMultClient_ImplementationA:
47 | return SandboxMultClient_ImplementationA(rolename, config, account_manager)
48 |
49 |
50 | class SandboxMultServer_Implementation1(SandboxMultServer):
51 | """
52 | First server implementation in the Sandbox protocol with some test instrumentation.
53 | This server implementation simply calculates a*b.
54 | """
55 | def __init__(self, rolename: str, config: NodeConfiguration):
56 | super().__init__(rolename, config) # Has no AccountManager
57 | self._log : List[SandboxLogEvent] | None = None
58 |
59 |
60 | def mult(self, a: float, b: float) -> float:
61 | # Here's the key 'mult' functionality:
62 | c = a*b
63 | if self._log is not None:
64 | self._log.append(SandboxLogEvent(a, b, c))
65 | return c
66 |
67 |
68 | def start_logging(self):
69 | self._log = []
70 |
71 |
72 | def get_and_clear_log(self):
73 | ret = self._log
74 | self._log = None
75 | return ret
76 |
77 |
78 | class SandboxMultServerDriver_Implementation1(NodeDriver):
79 | """
80 | Driver for the first server implementation, so this server implementation can be provisioned and unprovisioned for
81 | test sessions.
82 | """
83 | # Python 3.12 @override
84 | @staticmethod
85 | def test_plan_node_parameters() -> list[TestPlanNodeParameter]:
86 | return [ HOSTNAME_PAR ]
87 |
88 |
89 | # Python 3.12 @override
90 | def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]:
91 | return (
92 | NodeConfiguration(
93 | self,
94 | 'SandboxMultServer_Implementation1',
95 | FEDITEST_VERSION,
96 | test_plan_node.parameter(HOSTNAME_PAR)
97 | ),
98 | None
99 | )
100 |
101 |
102 | # Python 3.12 @override
103 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> SandboxMultServer_Implementation1:
104 | return SandboxMultServer_Implementation1(rolename, config)
105 |
106 |
107 | class SandboxMultServer_Implementation2Faulty(SandboxMultServer):
108 | """
109 | Second server implementation in the Sandbox protocol with some test instrumentation.
110 | This server calculates a*b through a for loop using integers rather than floats
111 | """
112 | def __init__(self, rolename: str, config: NodeConfiguration):
113 | super().__init__(rolename, config) # Has no AccountManager
114 | self._log : List[SandboxLogEvent] | None = None
115 |
116 |
117 | def mult(self, a: float, b: float) -> float:
118 | c = 0.0
119 | # Here's the key 'mult' functionality, but it only works for positive a's that are integers!
120 | a_int = int(a)
121 | for _ in range(0, a_int) :
122 | c += b
123 | if self._log is not None:
124 | self._log.append(SandboxLogEvent(a, b, c))
125 | return c
126 |
127 |
128 | def start_logging(self):
129 | self._log = []
130 |
131 |
132 | def get_and_clear_log(self):
133 | ret = self._log
134 | self._log = None
135 | return ret
136 |
137 |
138 | class SandboxMultServerDriver_Implementation2Faulty(NodeDriver):
139 | """
140 | Driver for the second server implementation, so this server implementation can be provisioned and unprovisioned for
141 | test sessions.
142 | """
143 | # Python 3.12 @override
144 | @staticmethod
145 | def test_plan_node_parameters() -> list[TestPlanNodeParameter]:
146 | return [ HOSTNAME_PAR ]
147 |
148 |
149 | # Python 3.12 @override
150 | def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]:
151 | return (
152 | NodeConfiguration(
153 | self,
154 | 'SandboxMultServer_Implementation2Faulty',
155 | FEDITEST_VERSION,
156 | test_plan_node.parameter(HOSTNAME_PAR)
157 | ),
158 | None
159 | )
160 |
161 |
162 | # Python 3.12 @override
163 | def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> SandboxMultServer_Implementation2Faulty:
164 | return SandboxMultServer_Implementation2Faulty(rolename, config)
165 |
--------------------------------------------------------------------------------
/src/feditest/protocols/activitypub/diag.py:
--------------------------------------------------------------------------------
1 | from typing import Any, cast
2 |
3 | from . import ActivityPubNode
4 | from feditest.protocols.web.diag import WebDiagClient, WebDiagServer
5 |
6 | # Note:
7 | # The data elements held by the classes here are all untyped. That's because we want to be able
8 | # to store data we receive even if it is invalid according to the spec.
9 | # check_integrity() helps figure out whether it is valid or not.
10 |
11 | class AnyObject:
12 | """
13 | This container is used to hold any instance of any of the ActivityStreams types.
14 | We use a generic container because we also want to be able to hold objects
15 | that are invalid according to the spec.
16 | """
17 | def __init__(self, uri: str, json: Any):
18 | self._uri = uri
19 | self._json = json
20 |
21 |
22 | def check_is_valid_object(self) -> bool:
23 | """
24 | Interpret this instance as an ActivityStreams Object, and check whether it is valid.
25 | """
26 | json = cast(dict, self._json)
27 | if 'type' not in json:
28 | return False
29 | type = json['type']
30 | if not isinstance(type,str):
31 | return False
32 | return 'Object' == type
33 |
34 |
35 | def as_actor(self) -> 'Actor':
36 | """
37 | Interpret this instance as an Actor, and return an instance of the Actor class.
38 | """
39 | # FIXME: check that this is indeed a valid Actor, and throw exception if it is not
40 | return Actor(self)
41 |
42 |
43 | def as_collection(self) -> 'Collection':
44 | """
45 | Interpret this instance as a Collection, and return an instance of the Collection class.
46 | """
47 | # FIXME: check that this is indeed a valid Collection, and throw exception if it is not
48 | return Collection(self)
49 |
50 |
51 | def json_field(self, name:str):
52 | """
53 | Convenience method to access field 'name' in the JSON.
54 | """
55 | json = cast(dict, self._json)
56 | return json.get(name)
57 |
58 |
59 | class Actor:
60 | """
61 | A facade in front of AnyObject that interprets AnyObject as an Actor.
62 | """
63 | def __init__(self, delegate: AnyObject):
64 | self._delegate = delegate
65 |
66 |
67 | def followers_uri(self):
68 | # FIXME can this be in different format, like a list?
69 | return self._delegate.json_field('followers')
70 |
71 |
72 | def following_uri(self):
73 | # FIXME can this be in different format, like a list?
74 | return self._delegate.json_field('following')
75 |
76 |
77 | class Activity:
78 | """
79 | A facade in front of AnyObject that interprets AnyObject as an Activity.
80 | """
81 | def __init__(self, delegate: AnyObject):
82 | self._delegate = delegate
83 |
84 |
85 | class Collection:
86 | """
87 | A facade in front of AnyObject that interprets AnyObject as a Collection.
88 | """
89 | def __init__(self, delegate: AnyObject):
90 | self._delegate = delegate
91 |
92 |
93 | def is_ordered(self):
94 | return 'OrderedCollection' == self._delegate.json_field('type')
95 |
96 |
97 | # Work in progress
98 |
99 | # def items(self) -> Iterator[AnyObject]:
100 | # items = self._delegate.json_field('orderedItems' if self.is_ordered() else 'items')
101 | # if items is not None:
102 | # for item in items:
103 | # if isinstance(item,str):
104 | # yield AnyObject(item)
105 | # else:
106 | # raise Exception(f'Cannot process yet: {item}')
107 | # elif first := self._delegate.json_field('first') is not None:
108 | # if isinstance(first,str):
109 | # first_collection = AnyObject(first).as_collection()
110 | # yield from first_collection.items()
111 | # else:
112 | # raise Exception(f'Cannot process yet: {first}')
113 | # elif next := self._delegate.json_field('next') is not None:
114 | # if isinstance(next,str):
115 | # next_collection = AnyObject(next).as_collection()
116 | # yield from next_collection.items()
117 | # else:
118 | # raise Exception(f'Cannot process yet: {first}')
119 |
120 |
121 | # def contains(self, matcher: Callable[[AnyObject],bool]) -> bool:
122 | # """
123 | # Returns true if this Collection contains an item, as determined by the
124 | # matcher object. This method passes the members of this collection to the
125 | # matcher one at a time, and the matcher decides when there is a match.
126 | # """
127 | # for item in self.items():
128 | # if matcher(item):
129 | # return True
130 | # return False
131 |
132 |
133 | # def contains_item_with_id(self, id: str) -> bool:
134 | # """
135 | # Convenience method that looks for items that are simple object identifiers.
136 | # FIXME: this can be much more complicated in ActivityStreams, but this
137 | # implementation is all we need right now.
138 | # """
139 | # return self.contains(lambda candidate: id == candidate if isinstance(candidate,str) else False)
140 |
141 |
142 | class ActivityPubDiagNode(WebDiagClient, WebDiagServer,ActivityPubNode):
143 | pass
144 |
145 | # Work in progress
146 |
147 | # def fetch_remote_actor_document(remote_actor_acct_uri: str) -> Actor:
148 | # pass
149 |
150 |
151 | # def set_inbox_uri_to(actor_acct_uri: str, inbox_uri: str | None):
152 | # pass
153 |
154 |
155 | # def set_outbox_uri_to(actor_acct_uri: str, outbox_uri: str | None):
156 | # pass
157 |
158 |
159 | # def add_to_followers_collection(actor_acct_uri: str, to_be_added_actor_acct_uri: str):
160 | # pass
161 |
162 |
163 | # def add_to_following_collection(actor_acct_uri: str, to_be_added_actor_acct_uri: str):
164 | # pass
165 |
166 |
167 | # def add_to_outbox(actor_acct_uri: str, to_be_added_activity: Activity):
168 | # pass
169 |
170 |
171 | # def add_to_inbox(actor_acct_uri: str, to_be_added_activity: Activity):
172 | # pass
173 |
174 |
175 | # def read_inbox_of(actor_acct_uri: str, inbox_collection: Collection):
176 | # pass
177 |
178 |
179 | # def read_outbox_of(actor_acct_uri: str, outbox_collection: Collection):
180 | # pass
--------------------------------------------------------------------------------
/src/feditest/testruncontroller.py:
--------------------------------------------------------------------------------
1 | """
2 | Classes that know how to control a TestRun.
3 | """
4 |
5 | from abc import ABC, abstractmethod
6 |
7 | import feditest.testrun
8 | from feditest.reporting import is_trace_active
9 |
10 | class TestRunControlException(Exception,ABC):
11 | """
12 | Superclass of all exceptions we use to control test run execution.
13 | """
14 | pass
15 |
16 |
17 | class AbortTestRunException(TestRunControlException):
18 | """
19 | Stop executing this test run as quickly as possible.
20 | Don't run any more test steps and tests. Shut down the current constellation and don't run any more sessions.
21 | """
22 | pass
23 |
24 |
25 | class AbortTestRunSessionException(TestRunControlException):
26 | """
27 | Stop executing this test run session as quickly as possible, and continue with the next test run session.
28 | """
29 | pass
30 |
31 |
32 | class AbortTestException(TestRunControlException):
33 | """
34 | Stop executing this test as quickly as possible, and continue with the next test in the current test run session.
35 | """
36 | pass
37 |
38 |
39 | class TestRunController(ABC):
40 | def __init__(self, run: 'feditest.testrun.TestRun' ):
41 | self.run = run
42 |
43 |
44 | @abstractmethod
45 | def determine_next_constellation_index(self, last_constellation_index: int = -1) -> int:
46 | """
47 | last_constellation_index: -1 means: we haven't started yet
48 | """
49 | ...
50 |
51 |
52 | @abstractmethod
53 | def determine_next_test_index(self, last_test_index: int = -1) -> int:
54 | """
55 | last_test_index: -1 means: we haven't started yet
56 | """
57 | ...
58 |
59 |
60 | @abstractmethod
61 | def determine_next_test_step_index(self, last_test_step_index: int = -1) -> int:
62 | """
63 | last_test_step_index: -1 means: we haven't started yet
64 | """
65 | ...
66 |
67 |
68 | class AutomaticTestRunController(TestRunController):
69 | def determine_next_constellation_index(self, last_constellation_index: int = -1) -> int:
70 | return last_constellation_index+1
71 |
72 |
73 | def determine_next_test_index(self, last_test_index: int = -1) -> int:
74 | return last_test_index+1
75 |
76 |
77 | def determine_next_test_step_index(self, last_test_step_index: int = -1) -> int:
78 | return last_test_step_index+1
79 |
80 |
81 | class InteractiveTestRunController(TestRunController):
82 | def determine_next_constellation_index(self, last_constellation_index: int = -1) -> int:
83 | """
84 | A TestRunSession with a certain TestRunConstellation has just completed. Which TestRunConstellation should
85 | we run it with next?
86 | """
87 | if last_constellation_index >= 0:
88 | prompt = 'Which Constellation to run tests with next? n(ext constellation), r(repeat just completed constellation), (constellation number), q(uit): '
89 | else:
90 | prompt = 'Which Constellation to run first? n(ext/first constellation), (constellation number), q(uit): '
91 | while True:
92 | answer = self._prompt_user(prompt)
93 | match answer:
94 | case 'n':
95 | return last_constellation_index+1
96 | case 'r':
97 | return last_constellation_index
98 | case 'q':
99 | raise AbortTestRunException()
100 | try:
101 | parsed = int(answer)
102 | if parsed >= 0:
103 | return parsed
104 | except ValueError:
105 | pass
106 | print('Command not recognized, try again.')
107 |
108 |
109 | def determine_next_test_index(self, last_test_index: int = -1) -> int:
110 | """
111 | A Test has just completed. Which Test should we run next?
112 | """
113 | if last_test_index >= 0:
114 | prompt = 'Which Test to run next? n(ext test), r(repeat just completed test), a(bort current session), q(uit): '
115 | else:
116 | prompt = 'Which Test to run first? n(ext/first test), (test number), a(bort current session), q(uit): '
117 | while True:
118 | answer = self._prompt_user(prompt)
119 | match answer:
120 | case 'n':
121 | return last_test_index+1
122 | case 'r':
123 | return last_test_index
124 | case 'a':
125 | raise AbortTestRunSessionException()
126 | case 'q':
127 | raise AbortTestRunException()
128 | try:
129 | parsed = int(answer)
130 | if parsed >= 0:
131 | return parsed
132 | except ValueError:
133 | pass
134 | print('Command not recognized, try again.')
135 |
136 |
137 | def determine_next_test_step_index(self, last_test_step_index: int = -1) -> int:
138 | """
139 | A Test Step as just completed. Which Test Step should we run next?
140 | """
141 | if last_test_step_index >= 0:
142 | prompt = 'Which Test Step to run next? n(ext test step), r(repeat just completed test test), c(ancel current test), a(bort current session), q(uit): '
143 | else:
144 | prompt = 'Which Test Step to run first? n(ext/first test step), (test step number), c(ancel current test), a(bort current session), q(uit): '
145 | while True:
146 | answer = self._prompt_user(prompt)
147 | match answer:
148 | case 'n':
149 | return last_test_step_index+1
150 | case 'r':
151 | return last_test_step_index
152 | case 'c':
153 | raise AbortTestException()
154 | case 'a':
155 | raise AbortTestRunSessionException()
156 | case 'q':
157 | raise AbortTestRunException()
158 | try:
159 | parsed = int(answer)
160 | if parsed >= 0:
161 | return parsed
162 | except ValueError:
163 | pass
164 | print('Command not recognized, try again.')
165 |
166 |
167 | def _prompt_user(self, question: str) -> str:
168 | # In case of debugging, there's a lot of output, and it can be hard to tell where the steps end
169 | if is_trace_active():
170 | print()
171 |
172 | ret = input(f'Interactive: { question }')
173 |
174 | if is_trace_active():
175 | print()
176 |
177 | return ret
178 |
--------------------------------------------------------------------------------
/src/feditest/cli/commands/run.py:
--------------------------------------------------------------------------------
1 | """
2 | Run one or more tests
3 | """
4 |
5 | from argparse import ArgumentParser, Namespace, _SubParsersAction
6 | from typing import cast
7 |
8 | import feditest
9 | from feditest.cli.utils import (
10 | create_plan_from_session_and_constellations,
11 | create_plan_from_testplan
12 | )
13 | from feditest.registry import Registry, set_registry_singleton
14 | from feditest.reporting import fatal, warning
15 | from feditest.testplan import TestPlan
16 | from feditest.testrun import TestRun
17 | from feditest.testruncontroller import AutomaticTestRunController, InteractiveTestRunController, TestRunController
18 | from feditest.testruntranscriptserializer.json import JsonTestRunTranscriptSerializer
19 | from feditest.testruntranscriptserializer.html import HtmlRunTranscriptSerializer
20 | from feditest.testruntranscriptserializer.summary import SummaryTestRunTranscriptSerializer
21 | from feditest.testruntranscriptserializer.tap import TapTestRunTranscriptSerializer
22 | from feditest.utils import FEDITEST_VERSION, hostname_validate
23 |
24 |
25 | def run(parser: ArgumentParser, args: Namespace, remaining: list[str]) -> int:
26 | """
27 | Run this command.
28 | """
29 | if len(remaining):
30 | parser.print_help()
31 | return 0
32 |
33 | feditest.load_default_tests()
34 | feditest.load_tests_from(args.testsdir)
35 |
36 | feditest.load_default_node_drivers()
37 | if args.nodedriversdir:
38 | feditest.load_node_drivers_from(args.nodedriversdir)
39 |
40 | if args.domain:
41 | set_registry_singleton(Registry.create(args.domain)) # overwrite
42 |
43 | # Determine testplan. While we are at it, check consistency of arguments.
44 | plan : TestPlan | None = None
45 | if args.testplan:
46 | plan = create_plan_from_testplan(args)
47 | else:
48 | plan = create_plan_from_session_and_constellations(args)
49 |
50 | if not plan:
51 | fatal('Cannot find or create test plan ')
52 | return 1 # make linter happy
53 |
54 | if not plan.is_compatible_type():
55 | warning(f'Test plan has unexpected type { plan.type }: incompatibilities may occur.')
56 | if not plan.has_compatible_version():
57 | warning(f'Test plan was created by FediTest { plan.feditest_version }, you are running FediTest { FEDITEST_VERSION }: incompatibilities may occur.')
58 | plan.check_can_be_executed()
59 |
60 | test_run = TestRun(plan, args.who)
61 | if args.interactive :
62 | warning('--interactive: implementation is incomplete')
63 | controller : TestRunController = InteractiveTestRunController(test_run)
64 | else:
65 | controller = AutomaticTestRunController(test_run)
66 | test_run.run(controller)
67 |
68 | transcript : feditest.testruntranscript.TestRunTranscript = test_run.transcribe()
69 |
70 | if isinstance(args.tap, str) or args.tap:
71 | TapTestRunTranscriptSerializer().write(transcript, cast(str|None, args.tap))
72 |
73 | if isinstance(args.html, str):
74 | HtmlRunTranscriptSerializer(args.template_path).write(transcript, args.html)
75 | elif args.html:
76 | warning('--html requires a filename: skipping')
77 | elif args.template_path:
78 | warning('--template-path only supported with --html. Ignoring.')
79 |
80 | if isinstance(args.json, str) or args.json:
81 | JsonTestRunTranscriptSerializer().write(transcript, args.json)
82 |
83 | if isinstance(args.summary, str) or args.summary:
84 | SummaryTestRunTranscriptSerializer().write(transcript, args.summary)
85 |
86 | if transcript.build_summary().n_failed > 0:
87 | print('FAILED.')
88 | return 1
89 | return 0
90 |
91 |
92 | def add_sub_parser(parent_parser: _SubParsersAction, cmd_name: str) -> ArgumentParser:
93 | """
94 | Add command-line options for this sub-command
95 | parent_parser: the parent argparse parser
96 | cmd_name: name of this command
97 | """
98 | # general flags and options
99 | parser = parent_parser.add_parser(cmd_name, help='Run one or more tests' )
100 | parser.add_argument('--testsdir', action='append', help='Directory or directories where to find tests')
101 | parser.add_argument('--nodedriversdir', action='append', help='Directory or directories where to find extra drivers for nodes that can be tested')
102 | parser.add_argument('--domain', type=hostname_validate, help='Local-only DNS domain for the DNS hostnames that are auto-generated for nodes')
103 | parser.add_argument('-i', '--interactive', action="store_true", help="Run the tests interactively")
104 | parser.add_argument('--who', action='store_true', help="Record who ran the test plan on what host.")
105 |
106 | # test plan options. We do not use argparse groups, as the situation is more complicated than argparse seems to support
107 | parser.add_argument('--name', default=None, required=False, help='Name of the generated test plan')
108 | parser.add_argument('--testplan', help='Name of the file that contains the test plan to run')
109 | parser.add_argument('--constellation', action='append', help='File(s) each containing a JSON fragment defining a constellation')
110 | parser.add_argument('--session', '--session-template', required=False, help='File(s) each containing a JSON fragment defining a test session')
111 | parser.add_argument('--node', action='append',
112 | help="Use = to specify that the node definition in 'file' is supposed to be used for constellation role 'role'")
113 | parser.add_argument('--filter-regex', default=None, help='Only include tests whose name matches this regular expression')
114 | parser.add_argument('--test', action='append', help='Run this/these named tests(s)')
115 |
116 | # output options
117 | parser.add_argument('--tap', nargs="?", const=True, default=False,
118 | help="Write results in TAP format to stdout, or to the provided file (if given).")
119 | html_group = parser.add_argument_group('html', 'HTML options')
120 | html_group.add_argument('--html',
121 | help="Write results in HTML format to the provided file.")
122 | html_group.add_argument('--template-path', required=False,
123 | help="When specifying --html, use this template path override (comma separated directory names)")
124 | parser.add_argument('--json', '--testresult', nargs="?", const=True, default=False,
125 | help="Write results in JSON format to stdout, or to the provided file (if given).")
126 | parser.add_argument('--summary', nargs="?", const=True, default=False,
127 | help="Write summary to stdout, or to the provided file (if given). This is the default if no other output option is given")
128 |
129 | return parser
130 |
--------------------------------------------------------------------------------
/tests.unit/test_60_use_manually_entered_application_name.py:
--------------------------------------------------------------------------------
1 | """
2 | Test that when the user enters an application name at the command-line, it shows up
3 | in the report.
4 | """
5 |
6 | import os
7 | import os.path
8 | import tempfile
9 |
10 | import pytest
11 | from bs4 import BeautifulSoup
12 |
13 | import feditest
14 | from feditest import test
15 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanConstellationNode, TestPlanSessionTemplate, TestPlanTestSpec
16 | from feditest.testrun import TestRun
17 | from feditest.testruncontroller import AutomaticTestRunController
18 | from feditest.testruntranscript import TestRunResultTranscript
19 | from feditest.testruntranscriptserializer.html import HtmlRunTranscriptSerializer
20 |
21 | APP_NAMES = [
22 | 'SENDER_APP_1',
23 | 'RECEIVER_APP_1',
24 | 'SENDER_APP_2',
25 | 'RECEIVER_APP_2'
26 | ]
27 |
28 | # Use the default NodeDrivers
29 |
30 | @pytest.fixture(scope="module", autouse=True)
31 | def init_node_drivers():
32 | """
33 | Cleanly define the NodeDrivers.
34 | """
35 | feditest.load_default_node_drivers()
36 |
37 |
38 | @pytest.fixture(scope="module", autouse=True)
39 | def init_tests():
40 | """
41 | Cleanly define some tests.
42 | """
43 | feditest.all_tests = {}
44 | feditest._registered_as_test = {}
45 | feditest._registered_as_test_step = {}
46 | feditest._loading_tests = True
47 |
48 | ##
49 | ## FediTest tests start here
50 | ##
51 |
52 | @test
53 | def passes() -> None:
54 | """
55 | This test always passes.
56 | """
57 | return
58 |
59 | ##
60 | ## FediTest tests end here
61 | ## (Don't forget the next two lines)
62 | ##
63 |
64 | feditest._loading_tests = False
65 | feditest._load_tests_pass2()
66 |
67 |
68 | @pytest.fixture(scope="module", autouse=True)
69 | def test_plan_fixture() -> TestPlan:
70 | """
71 | The test plan tests all known tests.
72 | """
73 | constellations = [
74 | TestPlanConstellation(
75 | {
76 | 'sender_node': TestPlanConstellationNode(
77 | 'FediverseSaasNodeDriver',
78 | {
79 | 'app' : APP_NAMES[0],
80 | 'hostname' : 'senderA'
81 | }),
82 | 'receiver_node': TestPlanConstellationNode(
83 | 'FediverseSaasNodeDriver',
84 | {
85 | 'app' : APP_NAMES[1],
86 | 'hostname' : 'senderB'
87 | })
88 | },
89 | 'constellation-1'),
90 | TestPlanConstellation(
91 | {
92 | 'sender_node': TestPlanConstellationNode(
93 | 'FediverseSaasNodeDriver',
94 | {
95 | 'app' : APP_NAMES[2],
96 | 'hostname' : 'senderA'
97 | }),
98 | 'receiver_node': TestPlanConstellationNode(
99 | 'FediverseSaasNodeDriver',
100 | {
101 | 'app' : APP_NAMES[3],
102 | 'hostname' : 'senderB'
103 | })
104 | },
105 | 'constellation-2')
106 | ]
107 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
108 | session = TestPlanSessionTemplate(tests, "Test a test that passes")
109 | ret = TestPlan(session, constellations)
110 | ret.properties_validate()
111 | # ret.print()
112 | return ret
113 |
114 |
115 | @pytest.fixture(scope="module")
116 | def transcript(test_plan_fixture: TestPlan) -> TestRunResultTranscript:
117 | test_plan_fixture.check_can_be_executed()
118 |
119 | test_run = TestRun(test_plan_fixture)
120 | controller = AutomaticTestRunController(test_run)
121 | test_run.run(controller)
122 |
123 | ret = test_run.transcribe()
124 | return ret
125 |
126 |
127 | @pytest.fixture(scope="module")
128 | def html_base_name(transcript: TestRunResultTranscript) -> str:
129 | def gen(tmpdirname: str) -> str:
130 | outbase = f'{ tmpdirname }/htmlout'
131 | HtmlRunTranscriptSerializer().write(transcript, f'{ outbase }.html')
132 | return outbase
133 |
134 | if False: # Change this to False if you want to see what is being generated
135 | with tempfile.TemporaryDirectory() as tmpdirname:
136 | ret = gen(tmpdirname)
137 | yield ret
138 | # The yield will cause the TemporaryDirectory to be deleted at the end of the test
139 | return None
140 |
141 | else:
142 | dir = f'/tmp/{ os.path.basename(__file__)[:-3] }' # without extension
143 | os.makedirs(dir, exist_ok=True)
144 | ret = gen(dir) # without extension
145 | yield ret # apparently yielding from one branch, and not the other does not work
146 | return None
147 |
148 |
149 | @pytest.fixture(scope="module")
150 | def main_html_soup(html_base_name: str) -> BeautifulSoup:
151 | content = None
152 | with open(f'{ html_base_name }.html') as fd:
153 | content = fd.read()
154 |
155 | ret = BeautifulSoup(content, 'html.parser')
156 | # print(ret.prettify())
157 | return ret
158 |
159 |
160 | @pytest.fixture(scope="module")
161 | def session0_html_soup(html_base_name: str) -> BeautifulSoup:
162 | content = None
163 | with open(f'{ html_base_name }.0.html') as fd:
164 | content = fd.read()
165 |
166 | ret = BeautifulSoup(content, 'html.parser')
167 | # print(ret.prettify())
168 | return ret
169 |
170 |
171 | ## Main HTML doc
172 |
173 | def test_main_title(main_html_soup: BeautifulSoup):
174 | title = main_html_soup.head.title.string
175 |
176 | # Don't have an empty string prior to the |
177 | assert title.split('|')[0].strip()
178 |
179 | # Don't have 'None" in the title
180 | assert 'None' not in title
181 |
182 |
183 | def test_main_h1(main_html_soup: BeautifulSoup):
184 | # Don't have 'None" in the title
185 | *_, h1 = main_html_soup.body.header.h1.strings
186 | assert 'None' not in h1
187 |
188 |
189 | def test_main_app_properties(main_html_soup: BeautifulSoup):
190 | # Use 'app' properties, not FediverseSaasNodeDriver
191 | for dl in main_html_soup.body.find_all('dl', class_='roles'):
192 | for dd in dl.find_all('dd'):
193 | assert dd.string in APP_NAMES
194 |
195 | # Session HTML doc
196 |
197 | def test_session0_title(session0_html_soup: BeautifulSoup):
198 | title = session0_html_soup.head.title.string
199 |
200 | # Don't have an empty string prior to the |
201 | assert title.split('|')[0].strip()
202 |
203 | # Don't have 'Session 0" in the title
204 | assert 'Session 0' not in title
205 |
206 |
207 | def test_session0_h1(session0_html_soup: BeautifulSoup):
208 | *_, h1 = session0_html_soup.body.header.h1.strings
209 |
210 | # Don't have an empty string prior to the |
211 | assert h1.split('|')[0].strip()
212 |
213 | # Don't have 'Session 0" in the title
214 | assert 'Session 0' not in h1
215 |
--------------------------------------------------------------------------------
/src/feditest/testruntranscriptserializer/html.py:
--------------------------------------------------------------------------------
1 | # import contextlib
2 | import html
3 | import os.path
4 | import re
5 | import shutil
6 | from typing import Any, Iterator
7 |
8 | import jinja2
9 |
10 | import feditest
11 | from feditest.reporting import fatal
12 | from feditest.testruntranscript import TestMetaTranscript, TestRunResultTranscript, TestRunSessionTranscript, TestRunTranscript
13 | from feditest.testruntranscriptserializer import TestRunTranscriptSerializer
14 |
15 |
16 | def _get_results_for(run_transcript: TestRunTranscript, session_transcript: TestRunSessionTranscript, test_meta: TestMetaTranscript) -> Iterator[TestRunResultTranscript | None]:
17 | """
18 | Determine the set of test results running test_meta within session_transcript, and return it as an Iterator.
19 | This is a set, not a single value, because we might run the same test multiple times (perhaps with differing role
20 | assignments) in the same session. The run_transcript is passed in because session_transcript does not have a pointer "up".
21 | """
22 | plan_session_template = run_transcript.plan.session_template
23 | for test_transcript in session_transcript.run_tests:
24 | plan_testspec = plan_session_template.tests[test_transcript.plan_test_index]
25 | if plan_testspec.name == test_meta.name:
26 | yield test_transcript.worst_result
27 | return None
28 |
29 |
30 | def _derive_full_and_local_filename(base: str, suffix: str) -> tuple[str,str]:
31 | """
32 | Given a base filename, derive another filename (e.g. generate a .css filename from an .html filename).
33 | Return the full filename with path, and the local filename
34 | """
35 | dir = os.path.dirname(base)
36 | local = os.path.basename(base)
37 | last_dot = local.rfind('.')
38 | if last_dot > 0:
39 | derived = f'{ local[0:last_dot] }{ suffix }'
40 | else:
41 | derived = f'{ local }.{ suffix }'
42 | return (os.path.join(dir, derived), derived)
43 |
44 |
45 | class HtmlRunTranscriptSerializer(TestRunTranscriptSerializer):
46 | """
47 | Generates the FediTest reports as HTML.
48 | If the transcript contains one session, it will generate one HTML file to the provided destination.
49 |
50 | If the transcript contains multiple sessions, it will generate one HTML file per session and
51 | an overview test matrix. The test matrix will be at the provided destination, and the session
52 | files will have longer file names starting with the filename of the destination.
53 |
54 | A CSS file will be written to the provided destination with an extra extension.
55 | """
56 |
57 | def __init__(self, template_path: str | None = None):
58 | if template_path:
59 | self.template_path = [ t.strip() for t in template_path.split(",") ]
60 | else:
61 | self.template_path = [ os.path.join(os.path.dirname(__file__), "templates/testplantranscript_default") ]
62 |
63 | self.jinja2_env = jinja2.Environment(
64 | loader=jinja2.FileSystemLoader(self.template_path),
65 | autoescape=jinja2.select_autoescape()
66 | )
67 | self.jinja2_env.filters["regex_sub"] = lambda s, pattern, replacement: re.sub(
68 | pattern, replacement, s
69 | )
70 |
71 |
72 | # Python 3.12 @override
73 | def write(self, transcript: TestRunTranscript, dest: str | None):
74 | if dest is None:
75 | fatal('Cannot write --html to stdout.')
76 | return # make linter happy
77 | if len(transcript.sessions) == 0:
78 | fatal('No session in transcript: cannot transcribe')
79 |
80 | ( cssfile, local_cssfile ) = _derive_full_and_local_filename(dest, '.css')
81 | base_context = dict(
82 | feditest=feditest,
83 | cssfile = local_cssfile,
84 | getattr=getattr,
85 | sorted=sorted,
86 | enumerate=enumerate,
87 | get_results_for=_get_results_for,
88 | remove_white=lambda s: re.sub("[ \t\n\a]", "_", str(s)),
89 | permit_line_breaks_in_identifier=lambda s: re.sub(
90 | r"(\.|::)", r"\1", s
91 | ),
92 | local_name_with_tooltip=lambda n: f'{ n.split(".")[-1] }',
93 | format_timestamp=lambda ts: ts.strftime("%Y:%m:%d-%H:%M:%S.%fZ") if ts else "",
94 | format_duration=lambda s: str(s), # makes it easier to change in the future
95 | len=len
96 | )
97 |
98 | try:
99 | if len(transcript.sessions) == 1:
100 | self.write_single_session(transcript, base_context, dest)
101 | else:
102 | self.write_matrix_and_sessions(transcript, base_context, dest)
103 |
104 | except jinja2.exceptions.TemplateNotFound as ex:
105 | msg = f"ERROR: template '{ex}' not found.\n"
106 | msg += "Searched in the following directories:"
107 | for entry in self.template_path:
108 | msg += f"\n {entry}"
109 | fatal(msg)
110 |
111 | # One this worked, we can add the CSS file
112 | for path in self.template_path:
113 | css_candidate = os.path.join(path, 'static', 'feditest.css')
114 | if os.path.exists(css_candidate):
115 | shutil.copyfile(css_candidate, cssfile)
116 | break
117 |
118 |
119 | def write_single_session(self, transcript: TestRunTranscript, context: dict[str, Any], dest: str):
120 | run_session = transcript.sessions[0]
121 | context.update(
122 | transcript=transcript,
123 | run_session=run_session,
124 | summary=run_session.build_summary() # if we use 'summary', we can use shared/summary.jinja2
125 | )
126 | with open(dest, "w") as fp:
127 | session_template = self.jinja2_env.get_template("session_single.jinja2")
128 | fp.write(session_template.render(**context))
129 |
130 |
131 | def write_matrix_and_sessions(self, transcript: TestRunTranscript, context: dict[str, Any], dest: str):
132 | matrix_context = dict(context)
133 | matrix_context.update(
134 | transcript=transcript,
135 | session_file_path=lambda session: _derive_full_and_local_filename(dest, f'.{ session.run_session_index }.html')[1],
136 | summary=transcript.build_summary() # if we use 'summary', we can use shared/summary.jinja2
137 | )
138 | with open(dest, "w") as fp:
139 | matrix_template = self.jinja2_env.get_template("matrix.jinja2")
140 | fp.write(matrix_template.render(**matrix_context))
141 |
142 | for run_session in transcript.sessions:
143 | session_context = dict(context)
144 | session_context.update(
145 | transcript=transcript,
146 | run_session=run_session,
147 | summary=run_session.build_summary(), # if we use 'summary', we can use shared/summary.jinja2
148 | matrix_file_path=os.path.basename(dest)
149 | )
150 | session_dest = _derive_full_and_local_filename(dest, f'.{ run_session.run_session_index }.html')[0]
151 | with open(session_dest, "w") as fp:
152 | session_template = self.jinja2_env.get_template("session_with_matrix.jinja2")
153 | fp.write(session_template.render(**session_context))
154 |
--------------------------------------------------------------------------------
/tests.unit/test_50_run_multistep_assertion_raises.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a multi-step test that raises various failures in different steps
3 | """
4 |
5 | import pytest
6 |
7 | import feditest
8 | from feditest import assert_that, step, test, InteropLevel, SpecLevel
9 | from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSessionTemplate, TestPlanTestSpec
10 | from feditest.testrun import TestRun
11 | from feditest.testruncontroller import AutomaticTestRunController
12 |
13 |
14 | @pytest.fixture(scope="module", autouse=True)
15 | def init_node_drivers():
16 | """
17 | Cleanly define the NodeDrivers.
18 | """
19 | feditest.all_node_drivers = {}
20 | feditest.load_default_node_drivers()
21 |
22 |
23 | @pytest.fixture(scope="module", autouse=True)
24 | def init_tests():
25 | """
26 | Cleanly define some tests.
27 | """
28 | feditest.all_tests = {}
29 | feditest._registered_as_test = {}
30 | feditest._registered_as_test_step = {}
31 | feditest._loading_tests = True
32 |
33 | ##
34 | ## FediTest tests start here
35 | ##
36 |
37 | @test
38 | class Example:
39 | """
40 | A multi-step test class that raises various failures in different steps.
41 | """
42 | def __init__(self):
43 | pass
44 |
45 |
46 | @step
47 | def step01_default(self) -> None:
48 | assert_that(False, 'This was the default!')
49 |
50 |
51 | @step
52 | def step02_must(self) -> None:
53 | assert_that(False, 'This was MUST!', spec_level=SpecLevel.MUST)
54 |
55 |
56 | @step
57 | def step03_should(self) -> None:
58 | assert_that(False, 'This was SHOULD!', spec_level=SpecLevel.SHOULD)
59 |
60 |
61 | @step
62 | def step04_implied(self) -> None:
63 | assert_that(False, 'This was IMPLIED!', spec_level=SpecLevel.IMPLIED)
64 |
65 |
66 | @step
67 | def step05_problem(self) -> None:
68 | assert_that(False, 'This is PROBLEM!', interop_level=InteropLevel.PROBLEM)
69 |
70 |
71 | @step
72 | def step06_degraded(self) -> None:
73 | assert_that(False, 'This is DEGRADED!', interop_level=InteropLevel.DEGRADED)
74 |
75 |
76 | @step
77 | def step07_unaffected(self) -> None:
78 | assert_that(False, 'This is UNAFFECTED!', interop_level=InteropLevel.UNAFFECTED)
79 |
80 |
81 | @step
82 | def step08_unknown(self) -> None:
83 | assert_that(False, 'This is UNKNOWN!', interop_level=InteropLevel.UNKNOWN)
84 |
85 |
86 | @step
87 | def step09_must_problem(self) -> None:
88 | assert_that(False, 'This was MUST, PROBLEM!', spec_level=SpecLevel.MUST, interop_level=InteropLevel.PROBLEM)
89 |
90 |
91 | @step
92 | def step10_must_degraded(self) -> None:
93 | assert_that(False, 'This was MUST, DEGRADED!', spec_level=SpecLevel.MUST, interop_level=InteropLevel.DEGRADED)
94 |
95 |
96 | @step
97 | def step11_must_unaffected(self) -> None:
98 | assert_that(False, 'This was MUST, UNAFFECTED!', spec_level=SpecLevel.MUST, interop_level=InteropLevel.UNAFFECTED)
99 |
100 |
101 | @step
102 | def step12_must_unknown(self) -> None:
103 | assert_that(False, 'This was MUST, UNKNOWN!', spec_level=SpecLevel.MUST, interop_level=InteropLevel.UNKNOWN)
104 |
105 |
106 | @step
107 | def step13_should_problem(self) -> None:
108 | assert_that(False, 'This was SHOULD, PROBLEM!', spec_level=SpecLevel.SHOULD, interop_level=InteropLevel.PROBLEM)
109 |
110 |
111 | @step
112 | def step14_should_degraded(self) -> None:
113 | assert_that(False, 'This was SHOULD, DEGRADED!', spec_level=SpecLevel.SHOULD, interop_level=InteropLevel.DEGRADED)
114 |
115 |
116 | @step
117 | def step15_should_unaffected(self) -> None:
118 | assert_that(False, 'This was SHOULD, UNAFFECTED!', spec_level=SpecLevel.SHOULD, interop_level=InteropLevel.UNAFFECTED)
119 |
120 |
121 | @step
122 | def step16_should_unkown(self) -> None:
123 | assert_that(False, 'This was SHOULD, UNKNOWN!', spec_level=SpecLevel.SHOULD, interop_level=InteropLevel.UNKNOWN)
124 |
125 |
126 | @step
127 | def step17_implied_problem(self) -> None:
128 | assert_that(False, 'This was IMPLIED, PROBLEM!', spec_level=SpecLevel.IMPLIED, interop_level=InteropLevel.PROBLEM)
129 |
130 |
131 | @step
132 | def step18_implied_degraded(self) -> None:
133 | assert_that(False, 'This was IMPLIED, DEGRADED!', spec_level=SpecLevel.IMPLIED, interop_level=InteropLevel.DEGRADED)
134 |
135 |
136 | @step
137 | def step19_implied_unaffected(self) -> None:
138 | assert_that(False, 'This was IMPLIED, UNAFFECTED!', spec_level=SpecLevel.IMPLIED, interop_level=InteropLevel.UNAFFECTED)
139 |
140 |
141 | @step
142 | def step20_implied_unknown(self) -> None:
143 | assert_that(False, 'This was IMPLIED, UNKNOWN!', spec_level=SpecLevel.IMPLIED, interop_level=InteropLevel.UNKNOWN)
144 |
145 |
146 | @step
147 | def step21_unspecified_problem(self) -> None:
148 | assert_that(False, 'This was UNSPECIFIED, PROBLEM!', spec_level=SpecLevel.UNSPECIFIED, interop_level=InteropLevel.PROBLEM)
149 |
150 |
151 | @step
152 | def step22_unspecified_degraded(self) -> None:
153 | assert_that(False, 'This was UNSPECIFIED, DEGRADED!', spec_level=SpecLevel.UNSPECIFIED, interop_level=InteropLevel.DEGRADED)
154 |
155 |
156 | @step
157 | def step23_unspecified_unaffected(self) -> None:
158 | assert_that(False, 'This was UNSPECIFIED, UNAFFECTED!', spec_level=SpecLevel.UNSPECIFIED, interop_level=InteropLevel.UNAFFECTED)
159 |
160 |
161 | @step
162 | def step24_unspecified_unknown(self) -> None:
163 | assert_that(False, 'This was UNSPECIFIED, UNKNOWN!', spec_level=SpecLevel.UNSPECIFIED, interop_level=InteropLevel.UNKNOWN)
164 |
165 | ##
166 | ## FediTest tests end here
167 | ## (Don't forget the next two lines)
168 | ##
169 |
170 | feditest._loading_tests = False
171 | feditest._load_tests_pass2()
172 |
173 |
174 | @pytest.fixture(autouse=True)
175 | def test_plan_fixture() -> TestPlan:
176 | """
177 | The test plan tests all known tests.
178 | """
179 |
180 | constellation = TestPlanConstellation({}, 'No nodes needed')
181 | tests = [ TestPlanTestSpec(name) for name in sorted(feditest.all_tests.keys()) if feditest.all_tests.get(name) is not None ]
182 | session = TestPlanSessionTemplate(tests, "Test a test whose steps raises multiple AssertionFailures")
183 | ret = TestPlan(session, [ constellation ])
184 | ret.properties_validate()
185 | return ret
186 |
187 |
188 | def test_run_testplan(test_plan_fixture: TestPlan):
189 | test_plan_fixture.check_can_be_executed()
190 |
191 | test_run = TestRun(test_plan_fixture)
192 | controller = AutomaticTestRunController(test_run)
193 | test_run.run(controller)
194 |
195 | transcript = test_run.transcribe()
196 | summary = transcript.build_summary()
197 |
198 | assert summary.n_total == 1
199 | assert summary.n_failed == 1
200 | assert summary.n_skipped == 0
201 | assert summary.n_errored == 0
202 | assert summary.n_passed == 0
203 |
204 | assert len(transcript.sessions) == 1
205 | assert len(transcript.sessions[0].run_tests) == 1
206 | assert len(transcript.sessions[0].run_tests[0].run_steps) == 1 # It never getst to the other steps
207 |
208 | assert transcript.sessions[0].run_tests[0].run_steps[0].result.type == 'AssertionFailure'
209 | assert transcript.sessions[0].run_tests[0].run_steps[0].result.spec_level == SpecLevel.MUST.name
210 | assert transcript.sessions[0].run_tests[0].run_steps[0].result.interop_level == InteropLevel.UNKNOWN.name
211 |
--------------------------------------------------------------------------------
/src/feditest/nodedrivers/wordpress/ubos.py:
--------------------------------------------------------------------------------
1 | """
2 | """
3 |
4 | import os
5 | from typing import cast
6 |
7 | from feditest.nodedrivers import (
8 | Account,
9 | AccountManager,
10 | DefaultAccountManager,
11 | NonExistingAccount,
12 | Node,
13 | NodeConfiguration
14 | )
15 | from feditest.nodedrivers.mastodon.ubos import MastodonUbosNodeConfiguration
16 | from feditest.nodedrivers.ubos import UbosNodeConfiguration, UbosNodeDriver
17 | from feditest.nodedrivers.wordpress import (
18 | OAUTH_TOKEN_ACCOUNT_FIELD,
19 | ROLE_ACCOUNT_FIELD,
20 | ROLE_NON_EXISTING_ACCOUNT_FIELD,
21 | USERID_ACCOUNT_FIELD,
22 | USERID_NON_EXISTING_ACCOUNT_FIELD,
23 | WordPressAccount,
24 | WordPressPlusPluginsNode
25 | )
26 | from feditest.protocols.fediverse import FediverseNonExistingAccount
27 | from feditest.reporting import trace
28 | from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField
29 |
30 |
31 |
32 | class WordPressUbosAccountManager(DefaultAccountManager):
33 | """
34 | Knows how to provision new accounts in WordPress
35 | """
36 | # Python 3.12 @override
37 | def set_node(self, node: Node) -> None:
38 | """
39 | We override this so we can insert the admin account in the list of accounts, now that the Node has been instantiated.
40 | """
41 | super().set_node(node)
42 |
43 | if not self._accounts_allocated_to_role and not self._accounts_not_allocated_to_role:
44 | config = cast(UbosNodeConfiguration, node.config)
45 | admin_account = WordPressAccount(None, config.admin_userid, None, 1) # We know this is account with internal identifier 1
46 | admin_account.set_node(node)
47 | self._accounts_not_allocated_to_role.append(admin_account)
48 |
49 |
50 | class WordPressPlusPluginsUbosNode(WordPressPlusPluginsNode):
51 | """
52 | A WordPress+plugins Node running on UBOS. This means we know how to interact with it exactly.
53 | """
54 | # Python 3.12 @override
55 | def provision_account_for_role(self, role: str | None = None) -> Account | None:
56 | trace('Provisioning new user')
57 | raise NotImplementedError('FIXME')
58 |
59 |
60 | def provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None:
61 | raise NotImplementedError('FIXME')
62 |
63 |
64 | def add_cert_to_trust_store(self, root_cert: str) -> None:
65 | config = cast(UbosNodeConfiguration, self.config)
66 | node_driver = cast(WordPressPlusPluginsUbosNodeDriver, self.node_driver)
67 |
68 | node_driver.add_cert_to_trust_store_via(root_cert, config.rshcmd)
69 |
70 |
71 | def remove_cert_from_trust_store(self, root_cert: str) -> None:
72 | config = cast(UbosNodeConfiguration, self.config)
73 | node_driver = cast(WordPressPlusPluginsUbosNodeDriver, self.node_driver)
74 |
75 | node_driver.remove_cert_from_trust_store_via(root_cert, config.rshcmd)
76 |
77 |
78 | # Python 3.12 @override
79 | def _provision_oauth_token_for(self, account: WordPressAccount, oauth_client_id: str) -> str :
80 | # Code from here: https://wordpress.org/support/topic/programmatically-obtaining-oauth-token-for-testing/
81 | # $desired_token = '123';
82 | # $user_id = 1;
83 | # $oauth = new Enable_Mastodon_Apps\Mastodon_OAuth();
84 | # $oauth->get_token_storage()->setAccessToken( $desired_token, $app->get_client_id(), $user_id, time() + HOUR_IN_SECONDS, $app->get_scopes() );
85 |
86 | trace(f'Provisioning OAuth token on {self} for user with name="{ account.userid }".')
87 | config = cast(UbosNodeConfiguration, self.config)
88 | node_driver = cast(WordPressPlusPluginsUbosNodeDriver, self.node_driver)
89 |
90 | token = os.urandom(16).hex()
91 | php_script = f"""
92 | get_token_storage()->setAccessToken( "{ token }", "{ oauth_client_id }", { account.internal_userid }, time() + HOUR_IN_SECONDS, 'read write follow push' );
99 | """
100 | dir = f'/ubos/http/sites/{ config.siteid }'
101 | cmd = f'cd { dir } && sudo sudo -u http php' # from user ubosdev -> root -> http
102 |
103 | trace( f'PHP script is "{ php_script }"')
104 | result = node_driver._exec_shell(cmd, config.rshcmd, stdin_content=php_script, capture_output=True)
105 | if result.returncode:
106 | raise Exception(self, f'Failed to create OAuth token for user with id="{ account.userid }", cmd="{ cmd }", stdout="{ result.stdout}", stderr="{ result.stderr }"')
107 | return token
108 |
109 |
110 | class WordPressPlusPluginsUbosNodeDriver(UbosNodeDriver):
111 | """
112 | Knows how to instantiate Mastodon via UBOS.
113 | """
114 | # Python 3.12 @override
115 | @staticmethod
116 | def test_plan_node_account_fields() -> list[TestPlanNodeAccountField]:
117 | return [ USERID_ACCOUNT_FIELD, OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD ]
118 |
119 |
120 | # Python 3.12 @override
121 | @staticmethod
122 | def test_plan_node_non_existing_account_fields() -> list[TestPlanNodeNonExistingAccountField]:
123 | return [ USERID_NON_EXISTING_ACCOUNT_FIELD, ROLE_NON_EXISTING_ACCOUNT_FIELD ]
124 |
125 |
126 | # Python 3.12 @override
127 | def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]:
128 | accounts : list[Account] = []
129 | if test_plan_node.accounts:
130 | for index, account_info in enumerate(test_plan_node.accounts):
131 | accounts.append(WordPressAccount.create_from_account_info_in_testplan(
132 | account_info,
133 | f'Constellation role "{ rolename }", NodeDriver "{ self }, Account { index }: '))
134 |
135 | non_existing_accounts : list[NonExistingAccount] = []
136 | if test_plan_node.non_existing_accounts:
137 | for index, non_existing_account_info in enumerate(test_plan_node.non_existing_accounts):
138 | non_existing_accounts.append(FediverseNonExistingAccount.create_from_non_existing_account_info_in_testplan(
139 | non_existing_account_info,
140 | f'Constellation role "{ rolename }", NodeDriver "{ self }, Non-existing account { index }: '))
141 |
142 | # Once has the Node has been instantiated (we can't do that here yet): if the user did not specify at least one Account, we add the admin account
143 |
144 | return (
145 | MastodonUbosNodeConfiguration.create_from_node_in_testplan(
146 | test_plan_node,
147 | self,
148 | appconfigjson = {
149 | "appid" : "wordpress",
150 | "accessoryids" : [
151 | "wordpress-plugin-activitypub",
152 | "wordpress-plugin-enable-mastodon-apps",
153 | "wordpress-plugin-friends",
154 | "wordpress-plugin-webfinger"
155 | ],
156 | "context" : "",
157 | "customizationpoints" : {
158 | "wordpress" : {
159 | "disablessrfprotection" : {
160 | "value" : True
161 | }
162 | }
163 | }
164 | },
165 | defaults = {
166 | 'app' : 'WordPress+plugins'
167 | }),
168 | WordPressUbosAccountManager(accounts, non_existing_accounts)
169 | )
170 |
171 | # Python 3.12 @override
172 | def _instantiate_ubos_node(self, rolename: str, config: UbosNodeConfiguration, account_manager: AccountManager) -> Node:
173 | return WordPressPlusPluginsUbosNode(rolename, config, account_manager)
174 |
--------------------------------------------------------------------------------