spaceforge 0.1.0.dev0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spaceforge/__init__.py +12 -4
- spaceforge/__main__.py +3 -3
- spaceforge/_version.py +0 -1
- spaceforge/_version_scm.py +34 -0
- spaceforge/cls.py +24 -14
- spaceforge/conftest.py +89 -0
- spaceforge/generator.py +129 -56
- spaceforge/plugin.py +199 -22
- spaceforge/runner.py +3 -15
- spaceforge/schema.json +45 -22
- spaceforge/templates/binary_install.sh.j2 +24 -0
- spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +24 -0
- spaceforge/{generator_test.py → test_generator.py} +265 -53
- spaceforge/test_generator_binaries.py +194 -0
- spaceforge/test_generator_core.py +180 -0
- spaceforge/test_generator_hooks.py +90 -0
- spaceforge/test_generator_parameters.py +59 -0
- spaceforge/test_plugin.py +357 -0
- spaceforge/test_plugin_file_operations.py +118 -0
- spaceforge/test_plugin_hooks.py +100 -0
- spaceforge/test_plugin_inheritance.py +102 -0
- spaceforge/{runner_test.py → test_runner.py} +5 -68
- spaceforge/test_runner_cli.py +69 -0
- spaceforge/test_runner_core.py +124 -0
- spaceforge/test_runner_execution.py +169 -0
- spaceforge-1.0.1.dist-info/METADATA +606 -0
- spaceforge-1.0.1.dist-info/RECORD +33 -0
- spaceforge/plugin_test.py +0 -621
- spaceforge-0.1.0.dev0.dist-info/METADATA +0 -163
- spaceforge-0.1.0.dev0.dist-info/RECORD +0 -19
- /spaceforge/{cls_test.py → test_cls.py} +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/WHEEL +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/entry_points.txt +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for PluginGenerator core functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import Mock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from spaceforge.generator import PluginGenerator
|
|
9
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestPluginGeneratorInitialization:
|
|
13
|
+
"""Test PluginGenerator initialization and configuration."""
|
|
14
|
+
|
|
15
|
+
def test_should_initialize_with_custom_paths(self) -> None:
|
|
16
|
+
"""Should accept and store custom plugin and output paths."""
|
|
17
|
+
# Arrange & Act
|
|
18
|
+
generator = PluginGenerator("custom_plugin.py", "custom_output.yaml")
|
|
19
|
+
|
|
20
|
+
# Assert
|
|
21
|
+
assert generator.plugin_path == "custom_plugin.py"
|
|
22
|
+
assert generator.output_path == "custom_output.yaml"
|
|
23
|
+
assert generator.plugin_class is None
|
|
24
|
+
assert generator.plugin_instance is None
|
|
25
|
+
assert generator.plugin_working_directory is None
|
|
26
|
+
|
|
27
|
+
def test_should_use_defaults_when_no_paths_provided(self) -> None:
|
|
28
|
+
"""Should use default paths when none specified."""
|
|
29
|
+
# Arrange & Act
|
|
30
|
+
generator = PluginGenerator()
|
|
31
|
+
|
|
32
|
+
# Assert
|
|
33
|
+
assert generator.plugin_path == "plugin.py"
|
|
34
|
+
assert generator.output_path == "plugin.yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestPluginGeneratorLoading:
|
|
38
|
+
"""Test plugin file loading functionality."""
|
|
39
|
+
|
|
40
|
+
def test_should_raise_file_not_found_when_plugin_file_missing(self) -> None:
|
|
41
|
+
"""Should raise FileNotFoundError when plugin file doesn't exist."""
|
|
42
|
+
# Arrange
|
|
43
|
+
generator = PluginGenerator("nonexistent.py")
|
|
44
|
+
|
|
45
|
+
# Act & Assert
|
|
46
|
+
with pytest.raises(FileNotFoundError, match="Plugin file not found"):
|
|
47
|
+
generator.load_plugin()
|
|
48
|
+
|
|
49
|
+
def test_should_raise_exception_when_plugin_file_has_syntax_errors(
|
|
50
|
+
self, temp_dir: str
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Should raise exception when plugin file has invalid Python syntax."""
|
|
53
|
+
# Arrange
|
|
54
|
+
invalid_path = os.path.join(temp_dir, "invalid.py")
|
|
55
|
+
with open(invalid_path, "w") as f:
|
|
56
|
+
f.write("invalid python syntax }")
|
|
57
|
+
|
|
58
|
+
generator = PluginGenerator(invalid_path)
|
|
59
|
+
|
|
60
|
+
# Act & Assert
|
|
61
|
+
with pytest.raises(Exception): # Could be syntax error or import error
|
|
62
|
+
generator.load_plugin()
|
|
63
|
+
|
|
64
|
+
def test_should_raise_value_error_when_no_spaceforge_plugin_found(
|
|
65
|
+
self, temp_dir: str
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Should raise ValueError when file has no SpaceforgePlugin subclass."""
|
|
68
|
+
# Arrange
|
|
69
|
+
no_plugin_path = os.path.join(temp_dir, "no_plugin.py")
|
|
70
|
+
with open(no_plugin_path, "w") as f:
|
|
71
|
+
f.write(
|
|
72
|
+
"""
|
|
73
|
+
class NotAPlugin:
|
|
74
|
+
pass
|
|
75
|
+
"""
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
generator = PluginGenerator(no_plugin_path)
|
|
79
|
+
|
|
80
|
+
# Act & Assert
|
|
81
|
+
with pytest.raises(ValueError, match="No SpaceforgePlugin subclass found"):
|
|
82
|
+
generator.load_plugin()
|
|
83
|
+
|
|
84
|
+
def test_should_load_plugin_successfully_when_valid_file_provided(
|
|
85
|
+
self, test_plugin_file: str
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Should successfully load plugin from valid file."""
|
|
88
|
+
# Arrange
|
|
89
|
+
generator = PluginGenerator(test_plugin_file)
|
|
90
|
+
|
|
91
|
+
# Act
|
|
92
|
+
generator.load_plugin()
|
|
93
|
+
|
|
94
|
+
# Assert
|
|
95
|
+
assert generator.plugin_class is not None
|
|
96
|
+
assert generator.plugin_instance is not None
|
|
97
|
+
assert generator.plugin_class.__name__ == "TestPlugin"
|
|
98
|
+
assert generator.plugin_working_directory == "/mnt/workspace/plugins/test"
|
|
99
|
+
|
|
100
|
+
@patch("spaceforge.generator.importlib.util.spec_from_file_location")
|
|
101
|
+
def test_should_raise_import_error_when_spec_is_none(
|
|
102
|
+
self, mock_spec: Mock, test_plugin_file: str
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Should raise ImportError when importlib spec creation fails."""
|
|
105
|
+
# Arrange
|
|
106
|
+
mock_spec.return_value = None
|
|
107
|
+
generator = PluginGenerator(test_plugin_file)
|
|
108
|
+
|
|
109
|
+
# Act & Assert
|
|
110
|
+
with pytest.raises(ImportError, match="Could not load plugin"):
|
|
111
|
+
generator.load_plugin()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestPluginGeneratorMetadata:
|
|
115
|
+
"""Test metadata extraction functionality."""
|
|
116
|
+
|
|
117
|
+
def test_should_extract_complete_metadata_when_all_attributes_present(self) -> None:
|
|
118
|
+
"""Should extract all metadata when plugin has complete attributes."""
|
|
119
|
+
|
|
120
|
+
# Arrange
|
|
121
|
+
class CompletePlugin(SpaceforgePlugin):
|
|
122
|
+
"""Complete test plugin."""
|
|
123
|
+
|
|
124
|
+
__plugin_name__ = "complete_test"
|
|
125
|
+
__version__ = "2.0.0"
|
|
126
|
+
__author__ = "Test Author"
|
|
127
|
+
|
|
128
|
+
generator = PluginGenerator()
|
|
129
|
+
generator.plugin_class = CompletePlugin
|
|
130
|
+
|
|
131
|
+
# Act
|
|
132
|
+
metadata = generator.get_plugin_metadata()
|
|
133
|
+
|
|
134
|
+
# Assert
|
|
135
|
+
assert metadata["name_prefix"] == "complete_test"
|
|
136
|
+
assert metadata["version"] == "2.0.0"
|
|
137
|
+
assert metadata["author"] == "Test Author"
|
|
138
|
+
assert metadata["description"] == "Complete test plugin."
|
|
139
|
+
|
|
140
|
+
def test_should_use_defaults_when_metadata_attributes_missing(self) -> None:
|
|
141
|
+
"""Should use default values when plugin metadata attributes are missing."""
|
|
142
|
+
|
|
143
|
+
# Arrange
|
|
144
|
+
class MinimalPlugin(SpaceforgePlugin):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
generator = PluginGenerator()
|
|
148
|
+
generator.plugin_class = MinimalPlugin
|
|
149
|
+
|
|
150
|
+
# Act
|
|
151
|
+
metadata = generator.get_plugin_metadata()
|
|
152
|
+
|
|
153
|
+
# Assert
|
|
154
|
+
assert metadata["name_prefix"] == "SpaceforgePlugin" # inherited
|
|
155
|
+
assert metadata["version"] == "1.0.0" # inherited from base
|
|
156
|
+
assert metadata["author"] == "Spacelift Team" # inherited from base
|
|
157
|
+
assert "MinimalPlugin" in metadata["description"]
|
|
158
|
+
|
|
159
|
+
def test_should_generate_name_from_class_name_when_plugin_name_missing(
|
|
160
|
+
self,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Should derive name from class name when __plugin_name__ not set."""
|
|
163
|
+
|
|
164
|
+
# Arrange
|
|
165
|
+
class MinimalPlugin: # Don't inherit from SpaceforgePlugin
|
|
166
|
+
__name__ = "MinimalPlugin"
|
|
167
|
+
|
|
168
|
+
generator = PluginGenerator()
|
|
169
|
+
generator.plugin_class = MinimalPlugin # type: ignore[assignment]
|
|
170
|
+
|
|
171
|
+
# Act
|
|
172
|
+
metadata = generator.get_plugin_metadata()
|
|
173
|
+
|
|
174
|
+
# Assert
|
|
175
|
+
assert (
|
|
176
|
+
metadata["name_prefix"] == "minimal"
|
|
177
|
+
) # class name lowercased with 'plugin' removed
|
|
178
|
+
assert metadata["version"] == "1.0.0" # default
|
|
179
|
+
assert metadata["author"] == "Unknown" # default
|
|
180
|
+
assert "MinimalPlugin" in metadata["description"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for PluginGenerator hook detection."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from pytest import MonkeyPatch
|
|
6
|
+
|
|
7
|
+
from spaceforge.generator import PluginGenerator
|
|
8
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestPluginGeneratorHooks:
|
|
12
|
+
"""Test hook method detection functionality."""
|
|
13
|
+
|
|
14
|
+
def test_should_detect_overridden_hook_methods(self) -> None:
|
|
15
|
+
"""Should identify hook methods that have been overridden in plugin."""
|
|
16
|
+
|
|
17
|
+
# Arrange
|
|
18
|
+
class HookedPlugin(SpaceforgePlugin):
|
|
19
|
+
def after_plan(self) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def before_apply(self) -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
generator = PluginGenerator()
|
|
26
|
+
generator.plugin_class = HookedPlugin
|
|
27
|
+
|
|
28
|
+
# Act
|
|
29
|
+
hooks = generator.get_available_hooks()
|
|
30
|
+
|
|
31
|
+
# Assert
|
|
32
|
+
assert "after_plan" in hooks
|
|
33
|
+
assert "before_apply" in hooks
|
|
34
|
+
assert len(hooks) == 2
|
|
35
|
+
|
|
36
|
+
def test_should_return_empty_list_when_no_hooks_overridden(self) -> None:
|
|
37
|
+
"""Should return empty list when plugin has no overridden hook methods."""
|
|
38
|
+
|
|
39
|
+
# Arrange
|
|
40
|
+
class NoHooksPlugin(SpaceforgePlugin):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
generator = PluginGenerator()
|
|
44
|
+
generator.plugin_class = NoHooksPlugin
|
|
45
|
+
|
|
46
|
+
# Act
|
|
47
|
+
hooks = generator.get_available_hooks()
|
|
48
|
+
|
|
49
|
+
# Assert
|
|
50
|
+
assert hooks == []
|
|
51
|
+
|
|
52
|
+
def test_contexts_should_not_have_duplicates_pip_install_in_hooks(
|
|
53
|
+
self, temp_dir: str, monkeypatch: MonkeyPatch
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Should not have duplicate hook methods in the list."""
|
|
56
|
+
|
|
57
|
+
# Arrange
|
|
58
|
+
plugin_content = """
|
|
59
|
+
from spaceforge import SpaceforgePlugin
|
|
60
|
+
class DuplicateHooksPlugin(SpaceforgePlugin):
|
|
61
|
+
def before_init(self) -> None:
|
|
62
|
+
pass
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
requirements_content = """
|
|
66
|
+
requests
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
plugin_path = os.path.join(temp_dir, "plugin.py")
|
|
70
|
+
with open(plugin_path, "w") as f:
|
|
71
|
+
f.write(plugin_content)
|
|
72
|
+
with open(os.path.join(temp_dir, "requirements.txt"), "w") as f:
|
|
73
|
+
f.write(requirements_content)
|
|
74
|
+
|
|
75
|
+
# Change to temporary directory (automatically restored after test)
|
|
76
|
+
monkeypatch.chdir(temp_dir)
|
|
77
|
+
|
|
78
|
+
generator = PluginGenerator(plugin_path)
|
|
79
|
+
generator.load_plugin()
|
|
80
|
+
|
|
81
|
+
# Act
|
|
82
|
+
contexts = generator.get_plugin_contexts()
|
|
83
|
+
hooks = contexts[0].hooks
|
|
84
|
+
assert hooks is not None
|
|
85
|
+
|
|
86
|
+
# Assert
|
|
87
|
+
processed_hooks = []
|
|
88
|
+
for hook in hooks["before_init"]:
|
|
89
|
+
assert hook not in processed_hooks
|
|
90
|
+
processed_hooks.append(hook)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for PluginGenerator parameter handling."""
|
|
2
|
+
|
|
3
|
+
from spaceforge.cls import Parameter
|
|
4
|
+
from spaceforge.generator import PluginGenerator
|
|
5
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestPluginGeneratorParameters:
|
|
9
|
+
"""Test parameter extraction and processing."""
|
|
10
|
+
|
|
11
|
+
def test_should_extract_parameters_when_defined(self) -> None:
|
|
12
|
+
"""Should extract and return parameter list when plugin defines them."""
|
|
13
|
+
|
|
14
|
+
# Arrange
|
|
15
|
+
class ParameterizedPlugin(SpaceforgePlugin):
|
|
16
|
+
__parameters__ = [
|
|
17
|
+
Parameter(
|
|
18
|
+
name="api_key",
|
|
19
|
+
description="API key for authentication",
|
|
20
|
+
required=True,
|
|
21
|
+
sensitive=True,
|
|
22
|
+
),
|
|
23
|
+
Parameter(
|
|
24
|
+
name="endpoint",
|
|
25
|
+
description="API endpoint URL",
|
|
26
|
+
required=False,
|
|
27
|
+
default="https://api.example.com",
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
generator = PluginGenerator()
|
|
32
|
+
generator.plugin_class = ParameterizedPlugin
|
|
33
|
+
|
|
34
|
+
# Act
|
|
35
|
+
parameters = generator.get_plugin_parameters()
|
|
36
|
+
|
|
37
|
+
# Assert
|
|
38
|
+
assert parameters is not None
|
|
39
|
+
assert len(parameters) == 2
|
|
40
|
+
assert parameters[0].name == "api_key"
|
|
41
|
+
assert parameters[0].sensitive is True
|
|
42
|
+
assert parameters[1].name == "endpoint"
|
|
43
|
+
assert parameters[1].default == "https://api.example.com"
|
|
44
|
+
|
|
45
|
+
def test_should_return_none_when_no_parameters_defined(self) -> None:
|
|
46
|
+
"""Should return None when plugin has no parameters."""
|
|
47
|
+
|
|
48
|
+
# Arrange
|
|
49
|
+
class NoParamsPlugin(SpaceforgePlugin):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
generator = PluginGenerator()
|
|
53
|
+
generator.plugin_class = NoParamsPlugin
|
|
54
|
+
|
|
55
|
+
# Act
|
|
56
|
+
parameters = generator.get_plugin_parameters()
|
|
57
|
+
|
|
58
|
+
# Assert
|
|
59
|
+
assert parameters is None
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Dict
|
|
6
|
+
from unittest.mock import Mock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestSpaceforgePluginInitialization:
|
|
14
|
+
"""Test SpaceforgePlugin initialization and configuration."""
|
|
15
|
+
|
|
16
|
+
def test_should_initialize_with_defaults_when_no_environment_set(self) -> None:
|
|
17
|
+
"""Should set default values when no environment variables are provided."""
|
|
18
|
+
# Arrange & Act
|
|
19
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
20
|
+
plugin = SpaceforgePlugin()
|
|
21
|
+
|
|
22
|
+
# Assert
|
|
23
|
+
assert plugin._api_token is False
|
|
24
|
+
assert plugin._api_endpoint is False
|
|
25
|
+
assert plugin._api_enabled is False
|
|
26
|
+
assert plugin._workspace_root == os.getcwd()
|
|
27
|
+
assert isinstance(plugin.logger, logging.Logger)
|
|
28
|
+
|
|
29
|
+
def test_should_enable_api_when_valid_credentials_provided(
|
|
30
|
+
self, mock_env: Dict[str, str]
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Should enable API access when both token and endpoint are provided."""
|
|
33
|
+
# Arrange & Act
|
|
34
|
+
with patch.dict(os.environ, mock_env, clear=True):
|
|
35
|
+
plugin = SpaceforgePlugin()
|
|
36
|
+
|
|
37
|
+
# Assert
|
|
38
|
+
assert plugin._api_token == "test_token"
|
|
39
|
+
assert plugin._api_endpoint == "https://test.spacelift.io"
|
|
40
|
+
assert plugin._api_enabled is True
|
|
41
|
+
assert plugin._workspace_root == os.getcwd()
|
|
42
|
+
|
|
43
|
+
def test_should_normalize_domain_with_trailing_slash(self) -> None:
|
|
44
|
+
"""Should remove trailing slash from domain URL."""
|
|
45
|
+
# Arrange
|
|
46
|
+
test_env = {
|
|
47
|
+
"SPACELIFT_API_TOKEN": "test_token",
|
|
48
|
+
"TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io/",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Act
|
|
52
|
+
with patch.dict(os.environ, test_env, clear=True):
|
|
53
|
+
plugin = SpaceforgePlugin()
|
|
54
|
+
|
|
55
|
+
# Assert
|
|
56
|
+
assert plugin._api_endpoint == "https://test.spacelift.io"
|
|
57
|
+
assert plugin._api_enabled is True
|
|
58
|
+
|
|
59
|
+
def test_should_disable_api_when_domain_has_no_https_prefix(self) -> None:
|
|
60
|
+
"""Should disable API when domain doesn't use HTTPS."""
|
|
61
|
+
# Arrange
|
|
62
|
+
test_env = {
|
|
63
|
+
"SPACELIFT_API_TOKEN": "test_token",
|
|
64
|
+
"TF_VAR_spacelift_graphql_endpoint": "test.spacelift.io",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Act
|
|
68
|
+
with patch.dict(os.environ, test_env, clear=True):
|
|
69
|
+
plugin = SpaceforgePlugin()
|
|
70
|
+
|
|
71
|
+
# Assert
|
|
72
|
+
assert plugin._api_endpoint == "test.spacelift.io"
|
|
73
|
+
assert plugin._api_enabled is False
|
|
74
|
+
|
|
75
|
+
def test_should_disable_api_when_only_token_provided(self) -> None:
|
|
76
|
+
"""Should disable API when only token is provided without domain."""
|
|
77
|
+
# Arrange & Act
|
|
78
|
+
with patch.dict(os.environ, {"SPACELIFT_API_TOKEN": "test_token"}, clear=True):
|
|
79
|
+
plugin = SpaceforgePlugin()
|
|
80
|
+
|
|
81
|
+
# Assert
|
|
82
|
+
assert plugin._api_enabled is False
|
|
83
|
+
|
|
84
|
+
def test_should_disable_api_when_only_domain_provided(self) -> None:
|
|
85
|
+
"""Should disable API when only domain is provided without token."""
|
|
86
|
+
# Arrange & Act
|
|
87
|
+
with patch.dict(
|
|
88
|
+
os.environ,
|
|
89
|
+
{"TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io"},
|
|
90
|
+
clear=True,
|
|
91
|
+
):
|
|
92
|
+
plugin = SpaceforgePlugin()
|
|
93
|
+
|
|
94
|
+
# Assert
|
|
95
|
+
assert plugin._api_enabled is False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestSpaceforgePluginLogging:
|
|
99
|
+
"""Test logging configuration and functionality."""
|
|
100
|
+
|
|
101
|
+
def test_should_configure_logger_with_correct_name_and_level(self) -> None:
|
|
102
|
+
"""Should set up logger with proper name and level."""
|
|
103
|
+
# Arrange & Act
|
|
104
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
105
|
+
plugin = SpaceforgePlugin()
|
|
106
|
+
|
|
107
|
+
# Assert
|
|
108
|
+
assert plugin.logger.name == "spaceforge.SpaceforgePlugin"
|
|
109
|
+
assert len(plugin.logger.handlers) >= 1
|
|
110
|
+
assert plugin.logger.getEffectiveLevel() <= logging.INFO
|
|
111
|
+
|
|
112
|
+
def test_should_enable_debug_logging_when_debug_env_set(self) -> None:
|
|
113
|
+
"""Should set DEBUG level when SPACELIFT_DEBUG environment variable is true."""
|
|
114
|
+
# Arrange
|
|
115
|
+
test_env = {"SPACELIFT_DEBUG": "true"}
|
|
116
|
+
|
|
117
|
+
# Act
|
|
118
|
+
with patch.dict(os.environ, test_env, clear=True):
|
|
119
|
+
plugin = SpaceforgePlugin()
|
|
120
|
+
|
|
121
|
+
# Assert
|
|
122
|
+
assert plugin.logger.level == logging.DEBUG
|
|
123
|
+
|
|
124
|
+
def test_should_include_run_id_in_log_format(self) -> None:
|
|
125
|
+
"""Should include run ID in log message format when available."""
|
|
126
|
+
# Arrange
|
|
127
|
+
test_env = {"TF_VAR_spacelift_run_id": "run-123"}
|
|
128
|
+
|
|
129
|
+
# Act
|
|
130
|
+
with patch.dict(os.environ, test_env, clear=True):
|
|
131
|
+
plugin = SpaceforgePlugin()
|
|
132
|
+
formatter = plugin.logger.handlers[0].formatter
|
|
133
|
+
|
|
134
|
+
record = logging.LogRecord(
|
|
135
|
+
name="test",
|
|
136
|
+
level=logging.INFO,
|
|
137
|
+
pathname="",
|
|
138
|
+
lineno=0,
|
|
139
|
+
msg="test message",
|
|
140
|
+
args=(),
|
|
141
|
+
exc_info=None,
|
|
142
|
+
)
|
|
143
|
+
record.levelname = "INFO"
|
|
144
|
+
|
|
145
|
+
# Assert
|
|
146
|
+
assert formatter is not None
|
|
147
|
+
formatted = formatter.format(record)
|
|
148
|
+
assert "[run-123]" in formatted or "[local]" in formatted
|
|
149
|
+
|
|
150
|
+
def test_logger_color_formatting(self) -> None:
|
|
151
|
+
"""Test color formatting for different log levels."""
|
|
152
|
+
plugin = SpaceforgePlugin()
|
|
153
|
+
formatter = plugin.logger.handlers[0].formatter
|
|
154
|
+
assert formatter is not None
|
|
155
|
+
|
|
156
|
+
# Test different log levels
|
|
157
|
+
levels_to_test = [
|
|
158
|
+
(logging.INFO, "INFO"),
|
|
159
|
+
(logging.DEBUG, "DEBUG"),
|
|
160
|
+
(logging.WARNING, "WARNING"),
|
|
161
|
+
(logging.ERROR, "ERROR"),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
for level, level_name in levels_to_test:
|
|
165
|
+
record = logging.LogRecord(
|
|
166
|
+
name="test",
|
|
167
|
+
level=level,
|
|
168
|
+
pathname="",
|
|
169
|
+
lineno=0,
|
|
170
|
+
msg="test message",
|
|
171
|
+
args=(),
|
|
172
|
+
exc_info=None,
|
|
173
|
+
)
|
|
174
|
+
record.levelname = level_name
|
|
175
|
+
formatted = formatter.format(record)
|
|
176
|
+
|
|
177
|
+
# Should contain color codes and plugin name
|
|
178
|
+
assert "\033[" in formatted # ANSI color codes
|
|
179
|
+
assert "(SpaceforgePlugin)" in formatted
|
|
180
|
+
assert "test message" in formatted
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Hook tests moved to test_plugin_hooks.py
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestSpaceforgePluginCLI:
|
|
187
|
+
"""Test command-line interface execution functionality."""
|
|
188
|
+
|
|
189
|
+
def test_should_execute_cli_command_and_log_output_on_success(self) -> None:
|
|
190
|
+
"""Should run CLI command and log output when execution succeeds."""
|
|
191
|
+
# Arrange
|
|
192
|
+
plugin = SpaceforgePlugin()
|
|
193
|
+
mock_process = Mock()
|
|
194
|
+
mock_process.communicate.return_value = (b"success output\n", None)
|
|
195
|
+
mock_process.returncode = 0
|
|
196
|
+
|
|
197
|
+
# Act
|
|
198
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
199
|
+
with patch.object(plugin.logger, "info") as mock_info:
|
|
200
|
+
mock_popen.return_value = mock_process
|
|
201
|
+
plugin.run_cli("echo", "test")
|
|
202
|
+
|
|
203
|
+
# Assert
|
|
204
|
+
mock_popen.assert_called_once_with(
|
|
205
|
+
("echo", "test"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
206
|
+
)
|
|
207
|
+
mock_info.assert_called_with("success output")
|
|
208
|
+
|
|
209
|
+
def test_should_log_error_when_cli_command_fails(self) -> None:
|
|
210
|
+
"""Should log error details when CLI command returns non-zero exit code."""
|
|
211
|
+
# Arrange
|
|
212
|
+
plugin = SpaceforgePlugin()
|
|
213
|
+
mock_process = Mock()
|
|
214
|
+
mock_process.communicate.return_value = (None, b"error output\n")
|
|
215
|
+
mock_process.returncode = 1
|
|
216
|
+
|
|
217
|
+
# Act
|
|
218
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
219
|
+
with patch.object(plugin.logger, "error") as mock_error:
|
|
220
|
+
mock_popen.return_value = mock_process
|
|
221
|
+
plugin.run_cli("false")
|
|
222
|
+
|
|
223
|
+
# Assert
|
|
224
|
+
mock_error.assert_any_call("Command failed with return code 1")
|
|
225
|
+
mock_error.assert_any_call("error output")
|
|
226
|
+
|
|
227
|
+
def test_run_cli_with_multiple_args(self) -> None:
|
|
228
|
+
"""Test CLI command with multiple arguments."""
|
|
229
|
+
plugin = SpaceforgePlugin()
|
|
230
|
+
|
|
231
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
232
|
+
mock_process = Mock()
|
|
233
|
+
mock_process.communicate.return_value = (b"", None)
|
|
234
|
+
mock_process.returncode = 0
|
|
235
|
+
mock_popen.return_value = mock_process
|
|
236
|
+
|
|
237
|
+
with patch.object(plugin.logger, "debug") as mock_debug:
|
|
238
|
+
plugin.run_cli("git", "status", "--porcelain")
|
|
239
|
+
|
|
240
|
+
mock_popen.assert_called_once_with(
|
|
241
|
+
("git", "status", "--porcelain"),
|
|
242
|
+
stdout=subprocess.PIPE,
|
|
243
|
+
stderr=subprocess.STDOUT,
|
|
244
|
+
)
|
|
245
|
+
mock_debug.assert_called_with("Running CLI command: git status --porcelain")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestSpaceforgePluginAPI:
|
|
249
|
+
"""Test GraphQL API interaction functionality."""
|
|
250
|
+
|
|
251
|
+
def test_should_exit_with_error_when_api_disabled(self) -> None:
|
|
252
|
+
"""Should exit with error message when API is not enabled."""
|
|
253
|
+
# Arrange
|
|
254
|
+
plugin = SpaceforgePlugin()
|
|
255
|
+
plugin._api_enabled = False
|
|
256
|
+
|
|
257
|
+
# Act & Assert
|
|
258
|
+
with patch.object(plugin.logger, "error") as mock_error:
|
|
259
|
+
with pytest.raises(SystemExit):
|
|
260
|
+
plugin.query_api("query { test }")
|
|
261
|
+
|
|
262
|
+
mock_error.assert_called_with(
|
|
263
|
+
'API is not enabled, please export "SPACELIFT_API_TOKEN" and "TF_VAR_spacelift_graphql_endpoint".'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def test_should_make_successful_api_request_with_correct_format(
|
|
267
|
+
self, mock_api_response: Mock
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Should execute GraphQL query with proper authentication and format."""
|
|
270
|
+
# Arrange
|
|
271
|
+
plugin = SpaceforgePlugin()
|
|
272
|
+
plugin._api_enabled = True
|
|
273
|
+
plugin._api_token = "test_token"
|
|
274
|
+
plugin._api_endpoint = "https://test.spacelift.io"
|
|
275
|
+
|
|
276
|
+
expected_data = {"data": {"test": "result"}}
|
|
277
|
+
mock_api_response.read.return_value = json.dumps(expected_data).encode("utf-8")
|
|
278
|
+
|
|
279
|
+
# Act
|
|
280
|
+
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
281
|
+
with patch("urllib.request.Request") as mock_request:
|
|
282
|
+
mock_urlopen.return_value.__enter__ = Mock(
|
|
283
|
+
return_value=mock_api_response
|
|
284
|
+
)
|
|
285
|
+
mock_urlopen.return_value.__exit__ = Mock(return_value=None)
|
|
286
|
+
|
|
287
|
+
result = plugin.query_api("query { test }")
|
|
288
|
+
|
|
289
|
+
# Assert
|
|
290
|
+
mock_request.assert_called_once()
|
|
291
|
+
call_args = mock_request.call_args[0]
|
|
292
|
+
assert call_args[0] == "https://test.spacelift.io"
|
|
293
|
+
|
|
294
|
+
request_data = json.loads(call_args[1].decode("utf-8"))
|
|
295
|
+
assert request_data["query"] == "query { test }"
|
|
296
|
+
|
|
297
|
+
headers = mock_request.call_args[0][2]
|
|
298
|
+
assert headers["Content-Type"] == "application/json"
|
|
299
|
+
assert headers["Authorization"] == "Bearer test_token"
|
|
300
|
+
|
|
301
|
+
assert result == expected_data
|
|
302
|
+
|
|
303
|
+
def test_query_api_with_variables(self) -> None:
|
|
304
|
+
"""Test API query with variables."""
|
|
305
|
+
plugin = SpaceforgePlugin()
|
|
306
|
+
plugin._api_enabled = True
|
|
307
|
+
plugin._api_token = "test_token"
|
|
308
|
+
plugin._api_endpoint = "https://test.spacelift.io"
|
|
309
|
+
|
|
310
|
+
mock_response_data = {"data": {"test": "result"}}
|
|
311
|
+
mock_response = Mock()
|
|
312
|
+
mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
|
|
313
|
+
|
|
314
|
+
variables = {"stackId": "test-stack"}
|
|
315
|
+
|
|
316
|
+
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
317
|
+
with patch("urllib.request.Request") as mock_request:
|
|
318
|
+
mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
|
|
319
|
+
mock_urlopen.return_value.__exit__ = Mock(return_value=None)
|
|
320
|
+
|
|
321
|
+
plugin.query_api(
|
|
322
|
+
"query ($stackId: ID!) { stack(id: $stackId) { name } }", variables
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Verify request data includes variables
|
|
326
|
+
request_data = json.loads(mock_request.call_args[0][1].decode("utf-8"))
|
|
327
|
+
assert request_data["variables"] == variables
|
|
328
|
+
|
|
329
|
+
def test_query_api_with_errors(self) -> None:
|
|
330
|
+
"""Test API query that returns errors."""
|
|
331
|
+
plugin = SpaceforgePlugin()
|
|
332
|
+
plugin._api_enabled = True
|
|
333
|
+
plugin._api_token = "test_token"
|
|
334
|
+
plugin._api_endpoint = "https://test.spacelift.io"
|
|
335
|
+
|
|
336
|
+
mock_response_data = {"errors": [{"message": "Test error"}]}
|
|
337
|
+
mock_response = Mock()
|
|
338
|
+
mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
|
|
339
|
+
|
|
340
|
+
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
341
|
+
with patch.object(plugin.logger, "error") as mock_error:
|
|
342
|
+
mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
|
|
343
|
+
mock_urlopen.return_value.__exit__ = Mock(return_value=None)
|
|
344
|
+
|
|
345
|
+
result = plugin.query_api("query { test }")
|
|
346
|
+
|
|
347
|
+
mock_error.assert_called_with("Error: [{'message': 'Test error'}]")
|
|
348
|
+
assert result == mock_response_data
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# File operation tests moved to test_plugin_file_operations.py
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# Inheritance tests moved to test_plugin_inheritance.py
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Edge case tests moved to test_plugin_inheritance.py
|