Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888cc4e3b2 | ||
|
|
f08ff0e7a3 | ||
|
|
772e1b1c6d | ||
|
|
bdfe2c561b | ||
|
|
02b27dd343 | ||
|
|
e5488fcfbd | ||
|
|
1a60739b5a | ||
|
|
50dcb67083 | ||
|
|
7b624b0525 | ||
|
|
570de94575 | ||
|
|
55583fe399 | ||
|
|
35f4fb7172 | ||
|
|
b3d47753a8 |
@@ -52,10 +52,8 @@ jobs:
|
|||||||
python3 -m devx.ci.detect_release_commit
|
python3 -m devx.ci.detect_release_commit
|
||||||
- name: Docker registry login
|
- name: Docker registry login
|
||||||
if: >-
|
if: >-
|
||||||
steps.check.outputs.is-release == 'false' && (
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'workflow_dispatch' ||
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false')
|
||||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
|
||||||
)
|
|
||||||
env:
|
env:
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
@@ -68,10 +66,8 @@ jobs:
|
|||||||
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||||
- name: Build and push tier images
|
- name: Build and push tier images
|
||||||
if: >-
|
if: >-
|
||||||
steps.check.outputs.is-release == 'false' && (
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'workflow_dispatch' ||
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && steps.check.outputs.is-release == 'false')
|
||||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
|
||||||
)
|
|
||||||
env:
|
env:
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
@@ -117,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
needs: [build-and-push]
|
needs: [build-and-push]
|
||||||
if: always() && needs.build-and-push.result == 'success' && needs.build-and-push.outputs.is-release == 'false'
|
if: always() && needs.build-and-push.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.43.0] - 2026-07-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add get_customer_vm_ip and get_observability_vm_ip to I/O check
|
||||||
|
|
||||||
|
## [0.42.0] - 2026-07-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add I/O function isolation check and skip integration tests
|
||||||
|
|
||||||
|
## [0.41.2] - 2026-07-13
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Auto-discover molecule root instead of hardcoding gitea-runner
|
||||||
|
|
||||||
|
## [0.41.1] - 2026-07-13
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Check_test_isolation accepts multiple --test-path values
|
||||||
|
|
||||||
## [0.41.0] - 2026-07-13
|
## [0.41.0] - 2026-07-13
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.41.0",
|
"devx>=0.43.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.41.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.43.0"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.41.0,<0.42"`).
|
> (for example, `"devx>=0.43.0,<0.44"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.41.0",
|
"devx>=0.43.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.41.0"` or `"devx>=0.41.0,<0.42"`.
|
Pin a specific version if needed: `"devx==0.43.0"` or `"devx>=0.43.0,<0.44"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.41.0",
|
"devx>=0.43.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.41.0",
|
"devx>=0.43.0",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.41.0"
|
__version__ = "0.43.0"
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ devx-check-test-speed:
|
|||||||
# This is also automatically enforced by the pytest plugin (pytest11 entry point).
|
# This is also automatically enforced by the pytest plugin (pytest11 entry point).
|
||||||
# Use this target for CI gates or pre-commit hooks.
|
# Use this target for CI gates or pre-commit hooks.
|
||||||
devx-check-test-isolation:
|
devx-check-test-isolation:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.check_test_isolation --test-path $(DEVX_TEST_PATHS)
|
@$(DEVX_PYTHON) -m devx.tools.check_test_isolation $(addprefix --test-path ,$(DEVX_TEST_PATHS))
|
||||||
|
|
||||||
# Check translation files for missing keys, dead keys, and missing languages.
|
# Check translation files for missing keys, dead keys, and missing languages.
|
||||||
# Runs automatically as part of devx-lint to shift-left translation issues
|
# Runs automatically as part of devx-lint to shift-left translation issues
|
||||||
|
|||||||
@@ -30,10 +30,24 @@ from devx.i18n import _
|
|||||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
|
||||||
DEFAULT_ROLES_ROOT = Path("ansible/roles")
|
DEFAULT_ROLES_ROOT = Path("ansible/roles")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_molecule_root() -> Path:
|
||||||
|
"""Auto-discover the single molecule directory under ansible/roles/.
|
||||||
|
|
||||||
|
If exactly one role has a molecule/ subdirectory, return it.
|
||||||
|
Otherwise, fall back to the first role with a molecule/ directory.
|
||||||
|
"""
|
||||||
|
roles_root = DEFAULT_ROLES_ROOT
|
||||||
|
if not roles_root.is_dir():
|
||||||
|
return roles_root / "gitea_runner" / "molecule" # sensible default for error message
|
||||||
|
mol_dirs = sorted(d / "molecule" for d in roles_root.iterdir() if (d / "molecule").is_dir())
|
||||||
|
if mol_dirs:
|
||||||
|
return mol_dirs[0]
|
||||||
|
return roles_root / "molecule" # will produce a clear "not found" error
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TestPair:
|
class TestPair:
|
||||||
"""A (scenario, platform) combination to test."""
|
"""A (scenario, platform) combination to test."""
|
||||||
@@ -83,7 +97,7 @@ class MultiRoleTestPair:
|
|||||||
def discover_scenarios(root: Path | None = None) -> list[str]:
|
def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||||
"""Return sorted list of molecule scenario directory names."""
|
"""Return sorted list of molecule scenario directory names."""
|
||||||
if root is None:
|
if root is None:
|
||||||
root = MOLECULE_ROOT
|
root = _default_molecule_root()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
||||||
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
||||||
@@ -318,7 +332,7 @@ def _write_github_env(key: str, value: str) -> None:
|
|||||||
"--molecule-root",
|
"--molecule-root",
|
||||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
default=None,
|
default=None,
|
||||||
help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.",
|
help="Custom molecule directory (single-role mode). Default: auto-discovered under ansible/roles/*/molecule.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--roles-root",
|
"--roles-root",
|
||||||
|
|||||||
@@ -21,7 +21,20 @@ import click
|
|||||||
|
|
||||||
from devx.molecule.platforms import PLATFORMS
|
from devx.molecule.platforms import PLATFORMS
|
||||||
|
|
||||||
ROLE_DIR = Path("ansible/roles/gitea-runner")
|
DEFAULT_ROLES_ROOT = Path("ansible/roles")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_role_dir() -> Path:
|
||||||
|
"""Auto-discover the single role directory with molecule scenarios."""
|
||||||
|
roles_root = DEFAULT_ROLES_ROOT
|
||||||
|
if not roles_root.is_dir():
|
||||||
|
return roles_root / "gitea_runner" # sensible default for error message
|
||||||
|
role_dirs = sorted(d for d in roles_root.iterdir() if (d / "molecule").is_dir())
|
||||||
|
if role_dirs:
|
||||||
|
return role_dirs[0]
|
||||||
|
return roles_root / "role" # will produce a clear error
|
||||||
|
|
||||||
|
|
||||||
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
||||||
|
|
||||||
|
|
||||||
@@ -72,15 +85,16 @@ def main(bin_dir: str) -> None:
|
|||||||
if not Path(molecule_bin).exists():
|
if not Path(molecule_bin).exists():
|
||||||
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
||||||
|
|
||||||
if not ROLE_DIR.exists():
|
role_dir = _default_role_dir()
|
||||||
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
|
if not role_dir.exists():
|
||||||
|
raise click.ClickException(f"Role directory not found: {role_dir}")
|
||||||
|
|
||||||
base_env = dict(os.environ)
|
base_env = dict(os.environ)
|
||||||
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||||
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
||||||
|
|
||||||
for platform in PLATFORMS:
|
for platform in PLATFORMS:
|
||||||
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
|
rc = _run_platform(molecule_bin, platform, role_dir, SCENARIOS, base_env)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
||||||
sys.exit(rc)
|
sys.exit(rc)
|
||||||
|
|||||||
@@ -145,13 +145,19 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
|
|||||||
"""Resolve the working directory for a molecule pair.
|
"""Resolve the working directory for a molecule pair.
|
||||||
|
|
||||||
For multi-role pairs (role non-empty), uses ``roles_root/role``.
|
For multi-role pairs (role non-empty), uses ``roles_root/role``.
|
||||||
For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``.
|
For single-role pairs, auto-discovers the first role with a molecule/
|
||||||
|
subdirectory under ``repo_root/ansible/roles/``.
|
||||||
"""
|
"""
|
||||||
if role:
|
if role:
|
||||||
if roles_root is None:
|
if roles_root is None:
|
||||||
roles_root = repo_root / "ansible" / "roles"
|
roles_root = repo_root / "ansible" / "roles"
|
||||||
return roles_root / role
|
return roles_root / role
|
||||||
return repo_root / "ansible" / "roles" / "gitea-runner"
|
roles_dir = repo_root / "ansible" / "roles"
|
||||||
|
if roles_dir.is_dir():
|
||||||
|
role_dirs = sorted(d for d in roles_dir.iterdir() if (d / "molecule").is_dir())
|
||||||
|
if role_dirs:
|
||||||
|
return role_dirs[0]
|
||||||
|
return roles_dir / "role" # will produce a clear "not found" error
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ Patterns detected:
|
|||||||
without patching it.
|
without patching it.
|
||||||
3. **Unpatched known-subprocess-helpers** — functions known to spawn
|
3. **Unpatched known-subprocess-helpers** — functions known to spawn
|
||||||
subprocesses (e.g. ``update_doc_versions``) called without patching.
|
subprocesses (e.g. ``update_doc_versions``) called without patching.
|
||||||
4. **Excessive iteration loops** — ``for _ in range(N)`` where N > 100.
|
4. **Unpatched I/O functions** — functions known to do filesystem or
|
||||||
|
network I/O (e.g. ``get_pat``, ``load_secrets``, ``requests.get``)
|
||||||
|
called without patching.
|
||||||
|
5. **Excessive iteration loops** — ``for _ in range(N)`` where N > 100.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -57,6 +60,25 @@ KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
|
|||||||
"run_cmd": "calls subprocess.run for shell commands",
|
"run_cmd": "calls subprocess.run for shell commands",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Functions known to do filesystem or network I/O that should be mocked in tests.
|
||||||
|
# Maps function name → description of what I/O it does.
|
||||||
|
# If a test calls one of these without a corresponding @patch, it's a violation.
|
||||||
|
KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
|
||||||
|
"get_pat": "reads ZITADEL PAT from filesystem/env (ZitadelAuth._iter_sources)",
|
||||||
|
"load_secrets": "reads YAML config file from disk",
|
||||||
|
"get_customer_secret": "reads customer-specific config from disk",
|
||||||
|
"get_customer_vm_ip": "queries Hetzner Cloud API for VM IP (network I/O)",
|
||||||
|
"get_observability_vm_ip": "queries Hetzner Cloud API for observability VM IP (network I/O)",
|
||||||
|
"requests.get": "performs HTTP GET to a real server",
|
||||||
|
"requests.post": "performs HTTP POST to a real server",
|
||||||
|
"requests.put": "performs HTTP PUT to a real server",
|
||||||
|
"requests.patch": "performs HTTP PATCH to a real server",
|
||||||
|
"requests.delete": "performs HTTP DELETE to a real server",
|
||||||
|
"urlopen": "performs HTTP request to a real server",
|
||||||
|
"httpx.get": "performs HTTP GET to a real server",
|
||||||
|
"httpx.post": "performs HTTP POST to a real server",
|
||||||
|
}
|
||||||
|
|
||||||
# Transitive dependencies: if a helper calls another helper that is patched,
|
# Transitive dependencies: if a helper calls another helper that is patched,
|
||||||
# the call is safe. Maps helper → set of function names it internally calls.
|
# the call is safe. Maps helper → set of function names it internally calls.
|
||||||
# If ANY of these are in the test's patches, the helper call is safe.
|
# If ANY of these are in the test's patches, the helper call is safe.
|
||||||
@@ -126,6 +148,20 @@ def _is_test_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|||||||
return node.name.startswith("test_")
|
return node.name.startswith("test_")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_integration_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||||
|
"""Check if a test function has @pytest.mark.integration decorator."""
|
||||||
|
for decorator in node.decorator_list:
|
||||||
|
# @pytest.mark.integration → ast.Attribute(attr='integration')
|
||||||
|
if isinstance(decorator, ast.Attribute) and decorator.attr == "integration":
|
||||||
|
return True
|
||||||
|
# @pytest.mark.integration(...) → ast.Call(func=ast.Attribute(attr='integration'))
|
||||||
|
if isinstance(decorator, ast.Call):
|
||||||
|
func = decorator.func
|
||||||
|
if isinstance(func, ast.Attribute) and func.attr == "integration":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_called_name(node: ast.Call) -> str | None:
|
def _get_called_name(node: ast.Call) -> str | None:
|
||||||
func = node.func
|
func = node.func
|
||||||
if isinstance(func, ast.Name):
|
if isinstance(func, ast.Name):
|
||||||
@@ -208,6 +244,11 @@ class TestIsolationVisitor(ast.NodeVisitor):
|
|||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Skip integration tests — they intentionally do real I/O
|
||||||
|
if _has_integration_marker(node):
|
||||||
|
self.generic_visit(node)
|
||||||
|
return
|
||||||
|
|
||||||
patches = _extract_patch_targets(node)
|
patches = _extract_patch_targets(node)
|
||||||
info = TestFunctionInfo(
|
info = TestFunctionInfo(
|
||||||
name=node.name,
|
name=node.name,
|
||||||
@@ -299,6 +340,34 @@ class TestIsolationVisitor(ast.NodeVisitor):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check 4: Known I/O functions (filesystem/network)
|
||||||
|
# Match by short name (e.g. "get_pat") or full name (e.g. "requests.get")
|
||||||
|
sn = short_name or ""
|
||||||
|
io_key = sn if sn in KNOWN_IO_FUNCTIONS else None
|
||||||
|
if io_key is None and full_name and full_name in KNOWN_IO_FUNCTIONS:
|
||||||
|
io_key = full_name
|
||||||
|
if io_key and not (
|
||||||
|
io_key in all_patches
|
||||||
|
or sn in all_patches
|
||||||
|
or any(io_key in p or sn in p for p in all_patches)
|
||||||
|
or any(p.endswith(f".{sn}") for p in all_patches)
|
||||||
|
):
|
||||||
|
self.violations.append(
|
||||||
|
Violation(
|
||||||
|
file=self.file_path,
|
||||||
|
line=node.lineno,
|
||||||
|
col=node.col_offset,
|
||||||
|
category="unpatched-io",
|
||||||
|
message=_(
|
||||||
|
"{func} called in test '{test}' without @patch — "
|
||||||
|
'this function {desc}. Add @patch("<module>.{func}").',
|
||||||
|
func=io_key,
|
||||||
|
test=self._current_function.name,
|
||||||
|
desc=KNOWN_IO_FUNCTIONS[io_key],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def visit_For(self, node: ast.For) -> None:
|
def visit_For(self, node: ast.For) -> None:
|
||||||
@@ -334,7 +403,13 @@ def find_test_files(test_path: Path) -> list[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def analyze_file(file_path: Path, max_loop_iterations: int = DEFAULT_MAX_LOOP_ITERATIONS) -> list[Violation]:
|
def analyze_file(file_path: Path, max_loop_iterations: int = DEFAULT_MAX_LOOP_ITERATIONS) -> list[Violation]:
|
||||||
"""Analyze a single test file for isolation violations."""
|
"""Analyze a single test file for isolation violations.
|
||||||
|
|
||||||
|
Files in ``integration/`` directories are skipped — integration tests
|
||||||
|
intentionally do real I/O (subprocess, network, filesystem).
|
||||||
|
"""
|
||||||
|
if "integration" in file_path.parts:
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
source = file_path.read_text()
|
source = file_path.read_text()
|
||||||
tree = ast.parse(source, filename=str(file_path))
|
tree = ast.parse(source, filename=str(file_path))
|
||||||
@@ -449,10 +524,12 @@ def pytest_collection_finish(session): # type: ignore[no-untyped-def] # pragma
|
|||||||
@click.command()
|
@click.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--test-path",
|
"--test-path",
|
||||||
|
"test_paths",
|
||||||
type=click.Path(exists=True, path_type=Path),
|
type=click.Path(exists=True, path_type=Path),
|
||||||
default=Path("tests/"),
|
multiple=True,
|
||||||
|
default=[Path("tests/")],
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Path to test directory or file to analyze.",
|
help="Path to test directory or file to analyze (can be specified multiple times).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--max-loop-iterations",
|
"--max-loop-iterations",
|
||||||
@@ -474,31 +551,35 @@ def pytest_collection_finish(session): # type: ignore[no-untyped-def] # pragma
|
|||||||
help="Comma-separated list of categories to check (default: all). "
|
help="Comma-separated list of categories to check (default: all). "
|
||||||
"Available: unpatched-subprocess, unpatched-sleep, unpatched-helper, excessive-iterations",
|
"Available: unpatched-subprocess, unpatched-sleep, unpatched-helper, excessive-iterations",
|
||||||
)
|
)
|
||||||
def cli(test_path: Path, max_loop_iterations: int, strict: bool, categories: str) -> None:
|
def cli(test_paths: tuple[Path, ...], max_loop_iterations: int, strict: bool, categories: str) -> None:
|
||||||
"""Check test files for un-hermetic patterns that cause slow or flaky tests."""
|
"""Check test files for un-hermetic patterns that cause slow or flaky tests."""
|
||||||
allowed: set[str] | None = None
|
allowed: set[str] | None = None
|
||||||
if categories:
|
if categories:
|
||||||
allowed = {c.strip() for c in categories.split(",")}
|
allowed = {c.strip() for c in categories.split(",")}
|
||||||
|
|
||||||
violations = analyze_test_files(test_path, max_loop_iterations, allowed)
|
all_violations: list[Violation] = []
|
||||||
|
total_files = 0
|
||||||
|
for test_path in test_paths:
|
||||||
|
violations = analyze_test_files(test_path, max_loop_iterations, allowed)
|
||||||
|
all_violations.extend(violations)
|
||||||
|
total_files += len(find_test_files(test_path))
|
||||||
|
|
||||||
if not violations:
|
if not all_violations:
|
||||||
file_count = len(find_test_files(test_path))
|
|
||||||
click.echo(
|
click.echo(
|
||||||
_("Test isolation check passed: {count} test files analyzed, no violations found.", count=file_count)
|
_("Test isolation check passed: {count} test files analyzed, no violations found.", count=total_files)
|
||||||
)
|
)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
_(
|
_(
|
||||||
"Test isolation check FAILED: {count} violation(s) found in {files} test file(s).",
|
"Test isolation check FAILED: {count} violation(s) found in {files} test file(s).",
|
||||||
count=len(violations),
|
count=len(all_violations),
|
||||||
files=len({v.file for v in violations}),
|
files=len({v.file for v in all_violations}),
|
||||||
),
|
),
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
click.echo("")
|
click.echo("")
|
||||||
for v in sorted(violations, key=lambda x: (str(x.file), x.line)):
|
for v in sorted(all_violations, key=lambda x: (str(x.file), x.line)):
|
||||||
click.echo(f" {v.format()}", err=True)
|
click.echo(f" {v.format()}", err=True)
|
||||||
|
|
||||||
click.echo("")
|
click.echo("")
|
||||||
|
|||||||
@@ -566,6 +566,147 @@ class TestKnownHelpers:
|
|||||||
assert "subprocess" in HELPER_INTERNAL_CALLS["update_doc_versions"]
|
assert "subprocess" in HELPER_INTERNAL_CALLS["update_doc_versions"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestIOFunctionChecks:
|
||||||
|
"""Tests for unpatched I/O function detection."""
|
||||||
|
|
||||||
|
def test_unpatched_get_pat_violation(self, tmp_path: Path) -> None:
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
from mymodule import get_pat
|
||||||
|
|
||||||
|
class TestExample:
|
||||||
|
def test_calls_get_pat(self) -> None:
|
||||||
|
result = get_pat("staging")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].category == "unpatched-io"
|
||||||
|
assert "get_pat" in violations[0].message
|
||||||
|
|
||||||
|
def test_patched_get_pat_no_violation(self, tmp_path: Path) -> None:
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from mymodule import get_pat
|
||||||
|
|
||||||
|
class TestExample:
|
||||||
|
@patch("mymodule.get_pat", return_value="pat")
|
||||||
|
def test_patched(self, mock) -> None:
|
||||||
|
result = get_pat("staging")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_unpatched_load_secrets_violation(self, tmp_path: Path) -> None:
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
from mymodule import load_secrets
|
||||||
|
|
||||||
|
class TestExample:
|
||||||
|
def test_calls_load_secrets(self) -> None:
|
||||||
|
result = load_secrets("staging")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].category == "unpatched-io"
|
||||||
|
assert "load_secrets" in violations[0].message
|
||||||
|
|
||||||
|
def test_patched_load_secrets_no_violation(self, tmp_path: Path) -> None:
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from mymodule import load_secrets
|
||||||
|
|
||||||
|
class TestExample:
|
||||||
|
@patch("mymodule.load_secrets", return_value={})
|
||||||
|
def test_patched(self, mock) -> None:
|
||||||
|
result = load_secrets("staging")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_unpatched_requests_get_violation(self, tmp_path: Path) -> None:
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class TestExample:
|
||||||
|
def test_calls_requests(self) -> None:
|
||||||
|
resp = requests.get("https://example.com")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert violations[0].category == "unpatched-io"
|
||||||
|
assert "requests.get" in violations[0].message or "get" in violations[0].message
|
||||||
|
|
||||||
|
def test_integration_marker_skips_subprocess(self, tmp_path: Path) -> None:
|
||||||
|
"""@pytest.mark.integration tests should not be flagged for subprocess.run."""
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_real_subprocess():
|
||||||
|
subprocess.run(["echo", "hello"])
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_integration_marker_skips_sleep(self, tmp_path: Path) -> None:
|
||||||
|
"""@pytest.mark.integration tests should not be flagged for time.sleep."""
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_real_sleep():
|
||||||
|
time.sleep(1)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_integration_marker_with_args_skips(self, tmp_path: Path) -> None:
|
||||||
|
"""@pytest.mark.integration(...) with args should also be skipped."""
|
||||||
|
file = _write_test_file(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.integration(scope="module")
|
||||||
|
def test_real_subprocess():
|
||||||
|
subprocess.run(["echo", "hello"])
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
def test_integration_directory_skipped(self, tmp_path: Path) -> None:
|
||||||
|
"""Files in integration/ directories should be skipped entirely."""
|
||||||
|
integration_dir = tmp_path / "integration"
|
||||||
|
integration_dir.mkdir()
|
||||||
|
file = integration_dir / "test_real_io.py"
|
||||||
|
file.write_text("import subprocess\ndef test_real_subprocess():\n subprocess.run(['echo', 'hello'])\n")
|
||||||
|
violations = analyze_file(file)
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
class TestCli:
|
||||||
"""Tests for the standalone CLI interface."""
|
"""Tests for the standalone CLI interface."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from devx.molecule.distribute_molecule import (
|
from devx.molecule.distribute_molecule import (
|
||||||
DEFAULT_ROLES_ROOT,
|
DEFAULT_ROLES_ROOT,
|
||||||
MOLECULE_ROOT,
|
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
MultiRoleTestPair,
|
MultiRoleTestPair,
|
||||||
TestPair,
|
TestPair,
|
||||||
|
_default_molecule_root,
|
||||||
_load_molecule_weights,
|
_load_molecule_weights,
|
||||||
_lpt_distribute,
|
_lpt_distribute,
|
||||||
_scenario_weight,
|
_scenario_weight,
|
||||||
@@ -43,8 +43,25 @@ class TestDiscoverScenarios:
|
|||||||
discover_scenarios(tmp_path / "nonexistent")
|
discover_scenarios(tmp_path / "nonexistent")
|
||||||
assert "not found" in str(exc.value)
|
assert "not found" in str(exc.value)
|
||||||
|
|
||||||
def test_default_root_constant(self) -> None:
|
def test_default_root_auto_discovery(self, tmp_path: Path) -> None:
|
||||||
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
|
"""_default_molecule_root auto-discovers first role with molecule/ dir."""
|
||||||
|
# When no roles exist, returns a fallback path
|
||||||
|
with patch("devx.molecule.distribute_molecule.DEFAULT_ROLES_ROOT", tmp_path / "roles"):
|
||||||
|
result = _default_molecule_root()
|
||||||
|
assert "molecule" in str(result)
|
||||||
|
|
||||||
|
# When a role has molecule/, it's discovered
|
||||||
|
(tmp_path / "roles" / "my_role" / "molecule").mkdir(parents=True)
|
||||||
|
with patch("devx.molecule.distribute_molecule.DEFAULT_ROLES_ROOT", tmp_path / "roles"):
|
||||||
|
result = _default_molecule_root()
|
||||||
|
assert result == tmp_path / "roles" / "my_role" / "molecule"
|
||||||
|
|
||||||
|
def test_default_root_no_molecule_dirs(self, tmp_path: Path) -> None:
|
||||||
|
"""When roles exist but none have molecule/, returns fallback path."""
|
||||||
|
(tmp_path / "roles" / "role_without_molecule").mkdir(parents=True)
|
||||||
|
with patch("devx.molecule.distribute_molecule.DEFAULT_ROLES_ROOT", tmp_path / "roles"):
|
||||||
|
result = _default_molecule_root()
|
||||||
|
assert result == tmp_path / "roles" / "molecule"
|
||||||
|
|
||||||
|
|
||||||
class TestPairEncoding:
|
class TestPairEncoding:
|
||||||
@@ -150,7 +167,7 @@ class TestCli:
|
|||||||
root = tmp_path / "molecule"
|
root = tmp_path / "molecule"
|
||||||
(root / "alpha").mkdir(parents=True)
|
(root / "alpha").mkdir(parents=True)
|
||||||
(root / "beta").mkdir(parents=True)
|
(root / "beta").mkdir(parents=True)
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--list"])
|
result = runner.invoke(cli, ["--list"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -176,7 +193,7 @@ class TestCli:
|
|||||||
root = tmp_path / "molecule"
|
root = tmp_path / "molecule"
|
||||||
for s in ["a", "b", "c"]:
|
for s in ["a", "b", "c"]:
|
||||||
(root / s).mkdir(parents=True)
|
(root / s).mkdir(parents=True)
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--max-runners", "3"])
|
result = runner.invoke(cli, ["--max-runners", "3"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -191,7 +208,7 @@ class TestCli:
|
|||||||
|
|
||||||
root = tmp_path / "molecule"
|
root = tmp_path / "molecule"
|
||||||
(root / "alpha").mkdir(parents=True)
|
(root / "alpha").mkdir(parents=True)
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
# 1-based index: "1" maps to internal 0
|
# 1-based index: "1" maps to internal 0
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
|
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
|
||||||
@@ -209,7 +226,7 @@ class TestGithubEnv:
|
|||||||
scenario = root / "alpha"
|
scenario = root / "alpha"
|
||||||
scenario.mkdir(parents=True)
|
scenario.mkdir(parents=True)
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -224,7 +241,7 @@ class TestGithubEnv:
|
|||||||
scenario = root / "alpha"
|
scenario = root / "alpha"
|
||||||
scenario.mkdir(parents=True)
|
scenario.mkdir(parents=True)
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
|
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
|
||||||
@@ -240,7 +257,7 @@ class TestGithubEnv:
|
|||||||
scenario = root / "alpha"
|
scenario = root / "alpha"
|
||||||
scenario.mkdir(parents=True)
|
scenario.mkdir(parents=True)
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||||
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
|
with patch("devx.molecule.distribute_molecule._default_molecule_root", return_value=root):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|||||||
@@ -104,12 +104,24 @@ class TestMain:
|
|||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Role directory not found" in result.output
|
assert "Role directory not found" in result.output
|
||||||
|
|
||||||
|
def test_role_dir_no_molecule(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Roles dir exists but no role has molecule/ — should error."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
bin_dir = tmp_path / ".venv" / "bin"
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
(bin_dir / "molecule").touch()
|
||||||
|
(tmp_path / "ansible" / "roles" / "role_without_molecule").mkdir(parents=True)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Role directory not found" in result.output
|
||||||
|
|
||||||
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
bin_dir = tmp_path / ".venv" / "bin"
|
||||||
bin_dir.mkdir(parents=True)
|
bin_dir.mkdir(parents=True)
|
||||||
(bin_dir / "molecule").touch()
|
(bin_dir / "molecule").touch()
|
||||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
(tmp_path / "ansible" / "roles" / "gitea-runner" / "molecule").mkdir(parents=True)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with patch("devx.molecule.molecule_all._run_platform", return_value=0):
|
with patch("devx.molecule.molecule_all._run_platform", return_value=0):
|
||||||
@@ -122,7 +134,7 @@ class TestMain:
|
|||||||
bin_dir = tmp_path / ".venv" / "bin"
|
bin_dir = tmp_path / ".venv" / "bin"
|
||||||
bin_dir.mkdir(parents=True)
|
bin_dir.mkdir(parents=True)
|
||||||
(bin_dir / "molecule").touch()
|
(bin_dir / "molecule").touch()
|
||||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
(tmp_path / "ansible" / "roles" / "gitea-runner" / "molecule").mkdir(parents=True)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with patch("devx.molecule.molecule_all._run_platform", return_value=1):
|
with patch("devx.molecule.molecule_all._run_platform", return_value=1):
|
||||||
|
|||||||
@@ -524,9 +524,17 @@ class TestResolveRoleDir:
|
|||||||
result = resolve_role_dir("docker-base", None, tmp_path)
|
result = resolve_role_dir("docker-base", None, tmp_path)
|
||||||
assert result == tmp_path / "ansible" / "roles" / "docker-base"
|
assert result == tmp_path / "ansible" / "roles" / "docker-base"
|
||||||
|
|
||||||
def test_single_role_uses_default(self, tmp_path: Path) -> None:
|
def test_single_role_auto_discovers(self, tmp_path: Path) -> None:
|
||||||
|
"""Single-role mode auto-discovers first role with molecule/ dir."""
|
||||||
|
roles_dir = tmp_path / "ansible" / "roles"
|
||||||
|
(roles_dir / "my_role" / "molecule").mkdir(parents=True)
|
||||||
result = resolve_role_dir("", None, tmp_path)
|
result = resolve_role_dir("", None, tmp_path)
|
||||||
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
|
assert result == roles_dir / "my_role"
|
||||||
|
|
||||||
|
def test_single_role_no_roles_returns_fallback(self, tmp_path: Path) -> None:
|
||||||
|
"""When no roles exist, returns a fallback path (will error at runtime)."""
|
||||||
|
result = resolve_role_dir("", None, tmp_path)
|
||||||
|
assert "roles" in str(result)
|
||||||
|
|
||||||
|
|
||||||
class TestCliMultiRole:
|
class TestCliMultiRole:
|
||||||
|
|||||||
Reference in New Issue
Block a user