Skip to content

Commit 4d08eb4

Browse files
authored
chore: add e2e tests (#68)
* chore: add e2e tests for cli, multi-workspace and auto-gen Assisted-By: Cursor Signed-off-by: Frank Kong <frkong@redhat.com> * chore: add backstage plugin e2e test and refactor e2e tests Assisted-By: Cursor Signed-off-by: Frank Kong <frkong@redhat.com> * chore: merge tests and run e2e tests in parallel Signed-off-by: Frank Kong <frkong@redhat.com> * chore: bump timeout Signed-off-by: Frank Kong <frkong@redhat.com> * chore: fixed ruff and mypy issues Assisted-By: Claude Code Signed-off-by: Frank Kong <frkong@redhat.com> --------- Signed-off-by: Frank Kong <frkong@redhat.com>
1 parent 7e2dbf7 commit 4d08eb4

48 files changed

Lines changed: 2341 additions & 1529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/integration-tests.mdc

Lines changed: 123 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,128 @@ def test_full_config_flow(self, make_config):
5050
- **Goal**: Validate that the container image works correctly with the provided `examples/`.
5151
- **Workflow**:
5252
1. **Get Image**: Use a pre-built image from the registry (CI) or build locally (development).
53-
2. **Run**: Execute the container mounting a `tmp_path` copy of an `examples/` directory.
54-
3. **Verify**: Check the output directory (mounted volume) for expected artifacts (e.g., `.tgz` files, `plugins-list.yaml`).
53+
2. **Run**: Execute the container mounting config fixtures and a temp output directory.
54+
3. **Verify**: Check the output directory for expected artifacts (`.tgz`, `.tgz.integrity`, `plugins-list.yaml`).
55+
56+
### Architecture
57+
58+
All E2E infrastructure lives in `tests/e2e/conftest.py`:
59+
60+
- **`ContainerResult`** dataclass: holds `returncode`, `output`, `output_dir`, and `log_file`.
61+
- **`run_factory_container`** (session fixture): returns a callable `_run(config_dir, extra_args, timeout)` that runs the container image, captures output, persists logs, and returns a `ContainerResult`.
62+
- **`PluginBuildTests`** base class: provides 6 standard test methods inherited by all single-workspace test classes. Expects two fixtures: `container_result` and `expected_plugins`.
63+
- **Helper functions**: `parse_plugins_from_config`, `find_outputs_for_plugin`, `get_output_tgz_files`, `get_output_integrity_files`, `assert_no_errors_in_logs`.
64+
65+
### PluginBuildTests Base Class
66+
67+
All single-workspace E2E tests inherit from `PluginBuildTests` (defined in `conftest.py`), which provides these test methods:
68+
69+
- `test_container_exits_successfully` — returncode == 0
70+
- `test_no_errors_in_logs` — no known error patterns in output
71+
- `test_all_plugins_produce_tgz` — each plugin has a matching `.tgz`
72+
- `test_all_plugins_produce_integrity` — each plugin has a `.tgz.integrity`
73+
- `test_output_tarballs_are_nonzero` — all `.tgz` files have size > 0
74+
- `test_output_count_matches_plugins` — at least as many tgz files as listed plugins
75+
76+
Subclasses must define two class-scoped fixtures with these exact names:
77+
78+
- `container_result` -> `ContainerResult`
79+
- `expected_plugins` -> `list[str]`
80+
81+
### Writing a New E2E Test
82+
83+
1. Create fixture files under `tests/e2e/fixtures/<test-name>/` (source.json, plugins-list.yaml, patches, overlays as needed).
84+
2. Create `tests/e2e/test_<name>.py` with fixtures and a class inheriting `PluginBuildTests`.
85+
3. Add case-specific tests as additional methods on the subclass.
86+
87+
**Standard single-workspace test** (simplest case):
88+
89+
```python
90+
from .conftest import FIXTURES_DIR, ContainerResult, PluginBuildTests, parse_plugins_from_config
91+
92+
MY_CONFIG_DIR = FIXTURES_DIR / "my-test" / "config"
93+
94+
@pytest.fixture(scope="class")
95+
def container_result(run_factory_container) -> ContainerResult:
96+
return run_factory_container(MY_CONFIG_DIR)
97+
98+
@pytest.fixture(scope="class")
99+
def expected_plugins() -> list[str]:
100+
return parse_plugins_from_config(MY_CONFIG_DIR)
101+
102+
@pytest.mark.e2e
103+
class TestMyPlugin(PluginBuildTests):
104+
pass # inherits all 6 standard tests
105+
```
106+
107+
**Build-arg auto-generation test** (container writes to config dir):
108+
109+
```python
110+
@pytest.fixture(scope="class")
111+
def config_dir(tmp_path_factory):
112+
dest = tmp_path_factory.mktemp("my-config")
113+
shutil.copytree(FIXTURE_DIR, dest, dirs_exist_ok=True)
114+
return dest
115+
116+
@pytest.fixture(scope="class")
117+
def container_result(run_factory_container, config_dir) -> ContainerResult:
118+
return run_factory_container(config_dir, extra_args=["--generate-build-args"])
119+
120+
@pytest.fixture(scope="class")
121+
def expected_plugins(config_dir) -> list[str]:
122+
return parse_plugins_from_config(config_dir)
123+
124+
@pytest.mark.e2e
125+
class TestMyAutoGen(PluginBuildTests):
126+
def test_generated_build_args_match_expected(self, config_dir):
127+
generated = yaml.safe_load((config_dir / "plugins-list.yaml").read_text())
128+
expected = yaml.safe_load((FIXTURE_DIR / "expected-plugins-list.yaml").read_text())
129+
assert generated == expected
130+
```
131+
132+
**CLI-only test** (no config files, uses CLI arguments):
133+
134+
```python
135+
@pytest.fixture(scope="class")
136+
def config_dir(tmp_path_factory):
137+
return tmp_path_factory.mktemp("cli-config") # empty dir
138+
139+
@pytest.fixture(scope="class")
140+
def container_result(run_factory_container, config_dir) -> ContainerResult:
141+
return run_factory_container(config_dir, extra_args=[
142+
"--source-repo", "https://github.com/...",
143+
"--source-ref", "abc123",
144+
"--workspace-path", "workspaces/my-plugin",
145+
])
146+
```
147+
148+
**Multi-workspace test** (does NOT use `PluginBuildTests` — uses per-workspace parametrization):
149+
150+
```python
151+
WORKSPACES = ["workspace-a", "workspace-b"]
152+
153+
@pytest.fixture(scope="class")
154+
def multi_ws_result(run_factory_container) -> ContainerResult:
155+
return run_factory_container(MULTI_WS_CONFIG_DIR)
156+
157+
@pytest.mark.e2e
158+
class TestMultiWorkspace:
159+
@pytest.mark.parametrize("workspace", WORKSPACES)
160+
def test_workspace_produces_tgz(self, multi_ws_result, workspace):
161+
plugins = parse_plugins_from_config(CONFIG_DIR / workspace)
162+
tgz = get_output_tgz_files(multi_ws_result.output_dir / workspace)
163+
for plugin in plugins:
164+
assert find_outputs_for_plugin(plugin, tgz)
165+
```
166+
167+
### Fixture File Conventions
168+
169+
- **`source.json`**: repo URL, ref, workspace-path.
170+
- **`plugins-list.yaml`**: plugin paths with optional build args.
171+
- **`expected-plugins-list.yaml`**: expected output for auto-gen tests (name avoids detection by factory).
172+
- **`patches/`**: `.patch` files applied in alphabetical order.
173+
- **`plugins/<path>/overlay/`**: overlay files copied over source before build.
174+
- **`backstage.json`**: optional Backstage version override.
55175

56176
### Image Source Strategy
57177

@@ -72,72 +192,14 @@ The container image is published to `quay.io/rhdh-community/dynamic-plugins-fact
72192
| Main branch | `{short_sha}` | `abc1234` |
73193
| Releases | `{version}-{release}`, `{version}`, `latest` | `1.8-0`, `1.8`, `latest` |
74194

75-
### E2E Fixture Pattern
76-
77-
```python
78-
import os
79-
import pytest
80-
import subprocess
81-
import shutil
82-
83-
@pytest.fixture(scope="session")
84-
def factory_image():
85-
"""Get or build the factory image for E2E tests.
86-
87-
Modes:
88-
- CI (default): Uses E2E_IMAGE env var or falls back to latest
89-
- Local: Set E2E_BUILD_LOCAL=true to build locally
90-
91-
Returns:
92-
str: The image tag to use for tests
93-
"""
94-
if os.environ.get("E2E_BUILD_LOCAL") == "true":
95-
# Local development: build image
96-
tag = "rhdh-factory:local"
97-
subprocess.run(["podman", "build", "-t", tag, "."], check=True)
98-
return tag
99-
100-
# CI/Default: use pre-built image
101-
return os.environ.get(
102-
"E2E_IMAGE",
103-
"quay.io/rhdh-community/dynamic-plugins-factory:latest"
104-
)
105-
106-
@pytest.mark.e2e
107-
def test_example_config_aws_ecs(factory_image, tmp_path):
108-
# Setup: Copy example to tmp_path
109-
example_dir = tmp_path / "example"
110-
shutil.copytree("examples/example-config-aws-ecs", example_dir)
111-
112-
output_dir = tmp_path / "output"
113-
output_dir.mkdir()
114-
115-
# Action: Run container
116-
subprocess.run([
117-
"podman", "run", "--rm",
118-
"-v", f"{example_dir}:/config:z",
119-
"-v", f"{output_dir}:/outputs:z",
120-
factory_image
121-
], check=True)
122-
123-
# Assertion: Check outputs
124-
assert (output_dir / "plugins-list.yaml").exists()
125-
```
126-
127195
### Running E2E Tests
128196

129197
```bash
130198
# Test against latest published image (default)
131-
pytest tests/e2e/ -m e2e
132-
133-
# Test against specific version
134-
E2E_IMAGE=quay.io/rhdh-community/dynamic-plugins-factory:1.8-0 pytest tests/e2e/ -m e2e
199+
E2E_IMAGE=quay.io/rhdh-community/dynamic-plugins-factory:latest pytest tests/e2e/ -m e2e
135200

136201
# Test against a PR image
137202
E2E_IMAGE=quay.io/rhdh-community/dynamic-plugins-factory:pr-123-abc1234 pytest tests/e2e/ -m e2e
138-
139-
# Test with local build (for Dockerfile changes)
140-
E2E_BUILD_LOCAL=true pytest tests/e2e/ -m e2e
141203
```
142204

143205
### CI Workflow Integration

.github/workflows/test.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ jobs:
4545
pip install -r requirements.txt
4646
pip install -r requirements.dev.txt
4747
48+
- name: Lint with ruff
49+
if: steps.skip_check.outputs.should_skip != 'true'
50+
run: |
51+
ruff check src/ tests/
52+
ruff format --check src/ tests/
53+
54+
- name: Type check with mypy
55+
if: steps.skip_check.outputs.should_skip != 'true'
56+
run: |
57+
mypy
58+
4859
- name: Run pytest
4960
if: steps.skip_check.outputs.should_skip != 'true'
5061
run: |

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,16 @@ pytest tests/test_config.py::TestPluginFactoryConfigLoadFromEnv::test_load_from_
272272
pytest tests/ --cov=src/rhdh_dynamic_plugin_factory --cov-report=term-missing
273273
```
274274

275+
### Running E2E Tests
276+
277+
E2E tests require a built image of the factory which is provided via the `E2E_IMAGE` environmental variable.
278+
279+
Use the `-n auto --dist loadscope` arguments to run tests in parallel.
280+
281+
```bash
282+
E2E_IMAGE=quay.io/rhdh-community/dynamic-plugins-factory:latest pytest -m e2e -n auto --dist loadscope
283+
```
284+
275285
This will show which lines of code are not covered by tests.
276286

277287
### Writing Tests

pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[tool.ruff]
2+
target-version = "py312"
3+
line-length = 120
4+
src = ["src", "tests"]
5+
6+
[tool.ruff.lint]
7+
select = [
8+
"E", # pycodestyle errors
9+
"W", # pycodestyle warnings
10+
"F", # pyflakes
11+
"I", # isort
12+
"UP", # pyupgrade
13+
]
14+
ignore = [
15+
"E501", # line too long — handled by ruff formatter
16+
]
17+
18+
[tool.mypy]
19+
python_version = "3.12"
20+
files = ["src"]
21+
strict = false
22+
warn_return_any = true
23+
warn_unused_configs = true
24+
disallow_untyped_defs = true

src/rhdh_dynamic_plugin_factory/__init__.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,56 +7,52 @@
77
"""
88

99
from .__version__ import __version__
10-
from .cli import main, create_parser
10+
from .cli import create_parser, main
1111
from .config import PluginFactoryConfig
12-
from .source_config import (
13-
SourceConfig,
14-
WorkspaceInfo,
15-
discover_workspaces,
16-
clone_workspaces_with_worktrees,
17-
)
18-
from .plugin_list_config import PluginListConfig
1912
from .exceptions import (
20-
PluginFactoryError,
2113
ConfigurationError,
2214
ExecutionError,
15+
PluginFactoryError,
2316
)
2417
from .logger import (
25-
setup_logging,
26-
get_logger,
2718
LEVELS,
19+
get_logger,
20+
setup_logging,
21+
)
22+
from .plugin_list_config import PluginListConfig
23+
from .source_config import (
24+
SourceConfig,
25+
WorkspaceInfo,
26+
clone_workspaces_with_worktrees,
27+
discover_workspaces,
2828
)
2929
from .utils import (
30-
run_command_with_streaming,
31-
display_export_results,
3230
clean_directory,
31+
display_export_results,
3332
prompt_or_clean_directory,
3433
repo_dir_name,
34+
run_command_with_streaming,
3535
)
3636

3737
__all__ = [
3838
# CLI
3939
"main",
4040
"create_parser",
41-
4241
# Configuration
4342
"PluginFactoryConfig",
4443
"SourceConfig",
4544
"WorkspaceInfo",
4645
"PluginListConfig",
4746
"discover_workspaces",
4847
"clone_workspaces_with_worktrees",
49-
5048
# Exceptions
5149
"PluginFactoryError",
5250
"ConfigurationError",
5351
"ExecutionError",
54-
5552
# Logging
5653
"setup_logging",
5754
"get_logger",
5855
"LEVELS",
59-
6056
# Utilities
6157
"run_command_with_streaming",
6258
"display_export_results",
@@ -66,4 +62,3 @@
6662
# Version
6763
"__version__",
6864
]
69-

src/rhdh_dynamic_plugin_factory/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44

55
import sys
66
from pathlib import Path
7+
78
try:
89
from .cli import main # For module execution
910
except ImportError:
1011
# For direct directory execution
1112
sys.path.insert(0, str(Path(__file__).parent.parent))
1213
from rhdh_dynamic_plugin_factory.cli import main
13-
14+
1415
if __name__ == "__main__":
1516
main()
16-
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
"""Version information for RHDH Dynamic Plugin Factory."""
22

33
__version__ = "1.10.0"
4-

0 commit comments

Comments
 (0)