初始提交
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, '-m', 'cli.main', *args],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
return _run_cli_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_text_cli_command(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_text_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return run_cli_command('--color=never', '--icon=none', *args)
|
||||
|
||||
return _run_text_cli_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_cli_completion_command(
|
||||
backend_dir: Path,
|
||||
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_cli_completion_command(
|
||||
*,
|
||||
comp_words: str,
|
||||
comp_cword: int | str,
|
||||
instruction: str = 'bash_complete',
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, '-m', 'cli.main'],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={
|
||||
**os.environ,
|
||||
'COMP_WORDS': comp_words,
|
||||
'COMP_CWORD': str(comp_cword),
|
||||
'_SHIERFENG_COMPLETE': instruction,
|
||||
},
|
||||
)
|
||||
|
||||
return _run_cli_completion_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]:
|
||||
def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
return _parse_json_stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_check_payload_contract() -> Callable[[dict[str, Any], bool], None]:
|
||||
def _assert_check_payload_contract(payload: dict[str, Any], allow_exit_code: bool) -> None:
|
||||
assert isinstance(payload, dict)
|
||||
assert isinstance(payload.get('ok'), bool)
|
||||
assert isinstance(payload.get('message'), str)
|
||||
|
||||
if payload['ok']:
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['error'], str)
|
||||
if 'exit_code' in payload:
|
||||
assert isinstance(payload['exit_code'], int)
|
||||
else:
|
||||
assert isinstance(payload.get('error'), str)
|
||||
if allow_exit_code:
|
||||
assert isinstance(payload.get('exit_code'), int)
|
||||
|
||||
return _assert_check_payload_contract
|
||||
@@ -0,0 +1,184 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def test_bootstrap_import_does_not_eagerly_load_heavy_runtime_modules() -> None:
|
||||
"""
|
||||
校验导入 `cli.bootstrap` 时不会提前加载重依赖模块。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.bootstrap',
|
||||
'config.database',
|
||||
'config.get_redis',
|
||||
'config.get_scheduler',
|
||||
'module_admin.service.server_service',
|
||||
'utils.transport_crypto_util',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
import cli.bootstrap
|
||||
|
||||
print(json.dumps({
|
||||
'config.database': 'config.database' in sys.modules,
|
||||
'config.get_redis': 'config.get_redis' in sys.modules,
|
||||
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
|
||||
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
|
||||
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'config.database': False,
|
||||
'config.get_redis': False,
|
||||
'config.get_scheduler': False,
|
||||
'module_admin.service.server_service': False,
|
||||
'utils.transport_crypto_util': False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_cli_does_not_eagerly_load_heavy_runtime_dependencies() -> None:
|
||||
"""
|
||||
校验构建 CLI 根应用时不会提前加载 DB、Redis、Scheduler 和业务服务依赖。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.main',
|
||||
'config.env',
|
||||
'config.database',
|
||||
'config.get_redis',
|
||||
'config.get_scheduler',
|
||||
'cli.tui.app',
|
||||
'module_admin.service.server_service',
|
||||
'module_admin.service.job_service',
|
||||
'module_admin.service.config_service',
|
||||
'module_generator.service.gen_service',
|
||||
'textual',
|
||||
'utils.transport_crypto_util',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
import cli.main
|
||||
|
||||
cli.main.CLI_APPLICATION_BUILDER.build()
|
||||
|
||||
print(json.dumps({
|
||||
'config.env': 'config.env' in sys.modules,
|
||||
'config.database': 'config.database' in sys.modules,
|
||||
'config.get_redis': 'config.get_redis' in sys.modules,
|
||||
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
|
||||
'cli.tui.app': 'cli.tui.app' in sys.modules,
|
||||
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
|
||||
'module_admin.service.job_service': 'module_admin.service.job_service' in sys.modules,
|
||||
'module_admin.service.config_service': 'module_admin.service.config_service' in sys.modules,
|
||||
'module_generator.service.gen_service': 'module_generator.service.gen_service' in sys.modules,
|
||||
'textual': 'textual' in sys.modules,
|
||||
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'config.env': False,
|
||||
'config.database': False,
|
||||
'config.get_redis': False,
|
||||
'config.get_scheduler': False,
|
||||
'cli.tui.app': False,
|
||||
'module_admin.service.server_service': False,
|
||||
'module_admin.service.job_service': False,
|
||||
'module_admin.service.config_service': False,
|
||||
'module_generator.service.gen_service': False,
|
||||
'textual': False,
|
||||
'utils.transport_crypto_util': False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_wizard_app_does_not_eagerly_load_flow_modules() -> None:
|
||||
"""
|
||||
校验构建 `wizard` 子应用时不会提前加载各个 flow 模块。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.wizard.commands',
|
||||
'cli.wizard.flows.app_run',
|
||||
'cli.wizard.flows.db_upgrade',
|
||||
'cli.wizard.flows.cache_clear',
|
||||
'cli.wizard.flows.gen_export',
|
||||
'cli.wizard.flows.gen_import',
|
||||
'cli.wizard.flows.prod_check',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
from cli.wizard.commands import WIZARD_COMMAND_BUILDER
|
||||
|
||||
WIZARD_COMMAND_BUILDER.build()
|
||||
|
||||
print(json.dumps({
|
||||
'cli.wizard.flows.app_run': 'cli.wizard.flows.app_run' in sys.modules,
|
||||
'cli.wizard.flows.db_upgrade': 'cli.wizard.flows.db_upgrade' in sys.modules,
|
||||
'cli.wizard.flows.cache_clear': 'cli.wizard.flows.cache_clear' in sys.modules,
|
||||
'cli.wizard.flows.gen_export': 'cli.wizard.flows.gen_export' in sys.modules,
|
||||
'cli.wizard.flows.gen_import': 'cli.wizard.flows.gen_import' in sys.modules,
|
||||
'cli.wizard.flows.prod_check': 'cli.wizard.flows.prod_check' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'cli.wizard.flows.app_run': False,
|
||||
'cli.wizard.flows.db_upgrade': False,
|
||||
'cli.wizard.flows.cache_clear': False,
|
||||
'cli.wizard.flows.gen_export': False,
|
||||
'cli.wizard.flows.gen_import': False,
|
||||
'cli.wizard.flows.prod_check': False,
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
|
||||
|
||||
|
||||
def test_app_config_json_output_is_pure_json(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'config', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert completed.stderr == ''
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['config'], dict)
|
||||
assert '\u001b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert '❌' not in completed.stdout
|
||||
|
||||
|
||||
def test_app_config_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'config', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'application:\n' in completed.stdout
|
||||
assert 'database:\n' in completed.stdout
|
||||
assert 'redis:\n' in completed.stdout
|
||||
assert 'logging:\n' in completed.stdout
|
||||
assert 'transport_crypto:\n' in completed.stdout
|
||||
|
||||
|
||||
def test_app_env_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'env', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'runtime:\n' in completed.stdout
|
||||
assert 'cli_env: dev\n' in completed.stdout
|
||||
assert 'config_env:' in completed.stdout
|
||||
assert 'app_env: dev\n' in completed.stdout
|
||||
assert 'env_file: .env.dev\n' in completed.stdout
|
||||
assert 'env_file_exists:' in completed.stdout
|
||||
assert 'backend_dir:' in completed.stdout
|
||||
assert 'python_executable:' in completed.stdout
|
||||
|
||||
|
||||
def test_app_doctor_text_output_has_stable_check_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'doctor', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'checks:\n' in completed.stdout
|
||||
assert 'database:' in completed.stdout
|
||||
assert 'redis:' in completed.stdout
|
||||
assert 'crypto:' in completed.stdout
|
||||
|
||||
|
||||
def test_app_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
assert_check_payload_contract: Callable[[dict, bool], None],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'doctor', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stderr == ''
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert set(payload) == {'env', 'database', 'redis', 'crypto', 'ok'}
|
||||
assert_check_payload_contract(payload['database'], True)
|
||||
assert_check_payload_contract(payload['redis'], True)
|
||||
assert_check_payload_contract(payload['crypto'], False)
|
||||
|
||||
|
||||
def test_app_env_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'env', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert set(payload) == {'ok', 'env', 'runtime'}
|
||||
assert isinstance(payload['runtime'], dict)
|
||||
assert set(payload['runtime']) == {
|
||||
'cliEnv',
|
||||
'configEnv',
|
||||
'appEnv',
|
||||
'envFile',
|
||||
'envFilePath',
|
||||
'envFileExists',
|
||||
'backendDir',
|
||||
'pythonExecutable',
|
||||
}
|
||||
assert payload['runtime']['cliEnv'] == 'dev'
|
||||
assert payload['runtime']['appEnv'] == 'dev'
|
||||
assert payload['runtime']['envFile'] == '.env.dev'
|
||||
assert isinstance(payload['runtime']['envFileExists'], bool)
|
||||
|
||||
|
||||
def test_app_routes_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'routes', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['count'], int)
|
||||
assert payload['count'] > 0
|
||||
assert payload['groupedRoutes'] is None
|
||||
assert payload['filters'] == {
|
||||
'pathPrefix': '',
|
||||
'method': '',
|
||||
'groupBy': 'none',
|
||||
'includeHidden': False,
|
||||
}
|
||||
assert isinstance(payload['routes'], list)
|
||||
assert payload['routes']
|
||||
|
||||
first_route = payload['routes'][0]
|
||||
assert set(first_route) == {
|
||||
'path',
|
||||
'methods',
|
||||
'name',
|
||||
'summary',
|
||||
'operationId',
|
||||
'tags',
|
||||
'includeInSchema',
|
||||
}
|
||||
assert isinstance(first_route['path'], str)
|
||||
assert isinstance(first_route['methods'], list)
|
||||
assert first_route['methods']
|
||||
assert all(isinstance(method, str) for method in first_route['methods'])
|
||||
assert isinstance(first_route['name'], str | None)
|
||||
assert isinstance(first_route['summary'], str | None)
|
||||
assert isinstance(first_route['operationId'], str | None)
|
||||
assert isinstance(first_route['tags'], list)
|
||||
assert all(isinstance(tag, str) for tag in first_route['tags'])
|
||||
assert isinstance(first_route['includeInSchema'], bool)
|
||||
|
||||
|
||||
def test_server_info_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'server-info', '--env=dev')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'host:\n' in completed.stdout
|
||||
assert 'cpu:\n' in completed.stdout
|
||||
assert 'memory:\n' in completed.stdout
|
||||
assert 'python:\n' in completed.stdout
|
||||
assert 'disks:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_health_text_output_has_stable_check_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'health', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'checks:\n' in completed.stdout
|
||||
assert 'database:' in completed.stdout
|
||||
assert 'redis:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_deps_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'deps', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'message:' in completed.stdout
|
||||
assert 'include_dev: false\n' in completed.stdout
|
||||
assert 'missing_required:' in completed.stdout
|
||||
assert 'packages:\n' in completed.stdout
|
||||
assert 'python:' in completed.stdout
|
||||
assert 'fastapi:' in completed.stdout
|
||||
assert 'typer:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_health_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
assert_check_payload_contract: Callable[[dict, bool], None],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'health', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stderr == ''
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert set(payload) == {'env', 'database', 'redis', 'ok'}
|
||||
assert_check_payload_contract(payload['database'], True)
|
||||
assert_check_payload_contract(payload['redis'], True)
|
||||
|
||||
|
||||
def test_ops_deps_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'deps', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert set(payload) == {'ok', 'message', 'missingRequired', 'includeDev', 'packages'}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['missingRequired'], list)
|
||||
assert payload['includeDev'] is False
|
||||
assert isinstance(payload['packages'], dict)
|
||||
assert 'python' in payload['packages']
|
||||
assert 'fastapi' in payload['packages']
|
||||
assert 'typer' in payload['packages']
|
||||
for package_payload in payload['packages'].values():
|
||||
assert isinstance(package_payload, dict)
|
||||
assert isinstance(package_payload.get('installed'), bool)
|
||||
assert isinstance(package_payload.get('version'), str)
|
||||
assert isinstance(package_payload.get('required'), bool)
|
||||
|
||||
|
||||
def test_ops_server_info_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'server-info', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert isinstance(payload['server'], dict)
|
||||
|
||||
server = payload['server']
|
||||
assert set(server) == {'cpu', 'mem', 'sys', 'py', 'sysFiles'}
|
||||
|
||||
cpu = server['cpu']
|
||||
assert set(cpu) == {'cpuNum', 'used', 'sys', 'free'}
|
||||
assert isinstance(cpu['cpuNum'], int)
|
||||
assert isinstance(cpu['used'], float | int | str)
|
||||
assert isinstance(cpu['sys'], float | int | str)
|
||||
assert isinstance(cpu['free'], float | int | str)
|
||||
|
||||
memory = server['mem']
|
||||
assert set(memory) == {'total', 'used', 'free', 'usage'}
|
||||
assert isinstance(memory['total'], str)
|
||||
assert isinstance(memory['used'], str)
|
||||
assert isinstance(memory['free'], str)
|
||||
assert isinstance(memory['usage'], float | int)
|
||||
|
||||
sys_info = server['sys']
|
||||
assert set(sys_info) == {'computerIp', 'computerName', 'osArch', 'osName', 'userDir'}
|
||||
assert all(isinstance(value, str) for value in sys_info.values())
|
||||
|
||||
py_info = server['py']
|
||||
assert set(py_info) == {
|
||||
'name',
|
||||
'version',
|
||||
'startTime',
|
||||
'runTime',
|
||||
'home',
|
||||
'total',
|
||||
'used',
|
||||
'free',
|
||||
'usage',
|
||||
}
|
||||
assert isinstance(py_info['name'], str)
|
||||
assert isinstance(py_info['version'], str)
|
||||
assert isinstance(py_info['startTime'], str)
|
||||
assert isinstance(py_info['runTime'], str)
|
||||
assert isinstance(py_info['home'], str)
|
||||
assert isinstance(py_info['total'], str)
|
||||
assert isinstance(py_info['used'], str)
|
||||
assert isinstance(py_info['free'], str)
|
||||
assert isinstance(py_info['usage'], float | int)
|
||||
|
||||
assert isinstance(server['sysFiles'], list)
|
||||
assert server['sysFiles']
|
||||
first_disk = server['sysFiles'][0]
|
||||
assert set(first_disk) == {'dirName', 'sysTypeName', 'typeName', 'total', 'used', 'free', 'usage'}
|
||||
assert all(isinstance(value, str) for value in first_disk.values())
|
||||
@@ -0,0 +1,207 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from cli.exit_codes import SUCCESS
|
||||
|
||||
|
||||
def test_root_help_shows_commands_without_completion_options(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('--help')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert 'Usage: shierfeng [OPTIONS] COMMAND [ARGS]...' in completed.stdout
|
||||
assert 'app' in completed.stdout
|
||||
assert 'db' in completed.stdout
|
||||
assert 'completion' in completed.stdout
|
||||
assert 'wizard' in completed.stdout
|
||||
assert 'tui' in completed.stdout
|
||||
assert completed.stdout.index('wizard') < completed.stdout.index('tui')
|
||||
assert '--install-completion' not in completed.stdout
|
||||
assert '--show-completion' not in completed.stdout
|
||||
|
||||
|
||||
def test_completion_show_bash_outputs_completion_script(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'show', 'bash')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stderr == ''
|
||||
assert '_SHIERFENG_COMPLETE=bash_complete' in completed.stdout
|
||||
assert '_shierfeng_completion()' in completed.stdout
|
||||
assert 'if command -v compopt >/dev/null 2>&1; then' in completed.stdout
|
||||
assert 'if complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null; then' in completed.stdout
|
||||
assert 'complete -F _shierfeng_completion shierfeng' in completed.stdout
|
||||
|
||||
|
||||
def test_completion_show_powershell_outputs_completion_script(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'show', 'powershell')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stderr == ''
|
||||
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in completed.stdout
|
||||
assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in completed.stdout
|
||||
|
||||
|
||||
def test_bash_completion_protocol_returns_candidates_without_not_supported_error(
|
||||
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_completion_command(comp_words='shierfeng comp', comp_cword=1)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert 'plain,completion' in completed.stdout
|
||||
assert 'not supported' not in completed.stdout.lower()
|
||||
assert completed.stderr == ''
|
||||
|
||||
|
||||
def test_powershell_completion_protocol_returns_candidates_without_not_supported_error(
|
||||
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_completion_command(
|
||||
comp_words='shierfeng comp',
|
||||
comp_cword='comp',
|
||||
instruction='powershell_complete',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert 'completion' in completed.stdout
|
||||
assert 'not supported' not in completed.stdout.lower()
|
||||
assert completed.stderr == ''
|
||||
|
||||
|
||||
def test_completion_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'doctor', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['message'] == 'completion 诊断信息已生成'
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'activeShell',
|
||||
'projectDir',
|
||||
'envChoices',
|
||||
'completeEnvVar',
|
||||
'recommendedInstallCommand',
|
||||
'shells',
|
||||
}
|
||||
assert isinstance(payload['envChoices'], list)
|
||||
assert payload['completeEnvVar'] == '_SHIERFENG_COMPLETE'
|
||||
assert isinstance(payload['shells'], dict)
|
||||
assert set(payload['shells']) == {'bash', 'zsh', 'fish', 'powershell'}
|
||||
assert payload['shells']['bash']['supported'] is True
|
||||
assert payload['shells']['fish']['autoDiscovery'] is True
|
||||
assert payload['shells']['powershell']['supported'] is True
|
||||
assert isinstance(payload['shells']['bash']['sourceCommand'], str)
|
||||
assert isinstance(payload['shells']['bash']['recommendedInstallCommand'], str)
|
||||
assert isinstance(payload['shells']['powershell']['sourceCommand'], str)
|
||||
assert isinstance(payload['shells']['powershell']['recommendedInstallCommand'], str)
|
||||
|
||||
|
||||
def test_completion_install_json_output_has_stable_contract(
|
||||
tmp_path: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
target_file = tmp_path / 'shierfeng.bash'
|
||||
rc_file = tmp_path / '.bashrc'
|
||||
completed = run_cli_command(
|
||||
'completion',
|
||||
'install',
|
||||
'--shell=bash',
|
||||
'--target-file',
|
||||
str(target_file),
|
||||
'--activate',
|
||||
'--rc-file',
|
||||
str(rc_file),
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'shell',
|
||||
'detectedShell',
|
||||
'targetFile',
|
||||
'activated',
|
||||
'activateRequested',
|
||||
'rcFile',
|
||||
'rcFileUpdated',
|
||||
'sourceCommand',
|
||||
'autoDiscovery',
|
||||
'activationRequired',
|
||||
'nextStep',
|
||||
'completeEnvVar',
|
||||
}
|
||||
assert payload['shell'] == 'bash'
|
||||
assert payload['targetFile'] == str(target_file.resolve())
|
||||
assert payload['activated'] is True
|
||||
assert payload['activateRequested'] is True
|
||||
assert payload['rcFile'] == str(rc_file.resolve())
|
||||
assert payload['rcFileUpdated'] is True
|
||||
assert payload['autoDiscovery'] is False
|
||||
assert payload['activationRequired'] is True
|
||||
assert isinstance(payload['nextStep'], str)
|
||||
assert target_file.exists()
|
||||
assert rc_file.exists()
|
||||
assert '_SHIERFENG_COMPLETE=bash_complete' in target_file.read_text(encoding='utf-8')
|
||||
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_completion_install_powershell_json_output_has_stable_contract(
|
||||
tmp_path: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
target_file = tmp_path / 'shierfeng.ps1'
|
||||
rc_file = tmp_path / 'Microsoft.PowerShell_profile.ps1'
|
||||
completed = run_cli_command(
|
||||
'completion',
|
||||
'install',
|
||||
'--shell=powershell',
|
||||
'--target-file',
|
||||
str(target_file),
|
||||
'--activate',
|
||||
'--rc-file',
|
||||
str(rc_file),
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['shell'] == 'powershell'
|
||||
assert payload['targetFile'] == str(target_file.resolve())
|
||||
assert payload['activated'] is True
|
||||
assert payload['activateRequested'] is True
|
||||
assert payload['rcFile'] == str(rc_file.resolve())
|
||||
assert payload['rcFileUpdated'] is True
|
||||
assert payload['autoDiscovery'] is False
|
||||
assert payload['activationRequired'] is True
|
||||
assert payload['sourceCommand'] == f'. "{target_file.resolve()}"'
|
||||
assert target_file.exists()
|
||||
assert rc_file.exists()
|
||||
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in target_file.read_text(encoding='utf-8')
|
||||
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_app_run_help_only_exposes_env_option(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'run', '--help')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert 'Usage: shierfeng app run [OPTIONS]' in completed.stdout
|
||||
assert '--env' in completed.stdout
|
||||
assert '--output' not in completed.stdout
|
||||
@@ -0,0 +1,40 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
|
||||
def test_config_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, REDIS_ERROR, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'databaseCount',
|
||||
'cacheCount',
|
||||
'missingInCacheCount',
|
||||
'orphanInCacheCount',
|
||||
'mismatchCount',
|
||||
'sampleLimit',
|
||||
'missingInCache',
|
||||
'orphanInCache',
|
||||
'mismatchKeys',
|
||||
'env',
|
||||
}
|
||||
assert isinstance(payload['databaseCount'], int)
|
||||
assert isinstance(payload['cacheCount'], int)
|
||||
assert isinstance(payload['missingInCache'], list)
|
||||
assert isinstance(payload['orphanInCache'], list)
|
||||
assert isinstance(payload['mismatchKeys'], list)
|
||||
@@ -0,0 +1,235 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
DB_HISTORY_TEST_LIMIT = 5
|
||||
|
||||
|
||||
def test_dangerous_command_is_rejected_without_yes_in_non_interactive_mode(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '已取消危险命令执行:db revision'
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_prod_dangerous_command_is_rejected_without_allow_prod(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'revision', '--env=prod', '--message=test-contract', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '生产环境默认禁止直接执行危险命令:db revision'
|
||||
assert '--allow-prod' in payload['hint']
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_dry_run_command_returns_structured_preview_payload(
|
||||
backend_dir: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'db',
|
||||
'upgrade',
|
||||
'--env=dev',
|
||||
'--revision=head',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert isinstance(payload['command'], list)
|
||||
assert payload['command'][-2:] == ['upgrade', 'head']
|
||||
assert payload['cwd'] == str(backend_dir)
|
||||
|
||||
|
||||
def test_db_heads_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'heads', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert set(first_item) == {'revision', 'downRevisions', 'branchLabels', 'dependsOn', 'doc', 'path'}
|
||||
assert isinstance(first_item['revision'], str)
|
||||
assert isinstance(first_item['downRevisions'], list)
|
||||
assert isinstance(first_item['branchLabels'], list)
|
||||
assert isinstance(first_item['dependsOn'], list)
|
||||
assert isinstance(first_item['doc'], str)
|
||||
assert isinstance(first_item['path'], str)
|
||||
|
||||
|
||||
def test_db_history_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'db',
|
||||
'history',
|
||||
'--env=dev',
|
||||
f'--limit={DB_HISTORY_TEST_LIMIT}',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'count', 'totalCount', 'limit', 'items', 'env'}
|
||||
assert payload['limit'] == DB_HISTORY_TEST_LIMIT
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['totalCount'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
|
||||
|
||||
def test_cache_ttl_json_output_has_stable_contract_for_missing_key_or_redis_error(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'cache',
|
||||
'ttl',
|
||||
'sys_config',
|
||||
'definitely_missing_cli_key',
|
||||
'--env=dev',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, REDIS_ERROR, RUNTIME_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert payload['message'] == '读取缓存剩余过期时间失败'
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'cacheName', 'cacheKey', 'fullCacheKey'}
|
||||
assert payload['ok'] is False
|
||||
assert payload['cacheName'] == 'sys_config'
|
||||
assert payload['cacheKey'] == 'definitely_missing_cli_key'
|
||||
assert payload['fullCacheKey'] == 'sys_config:definitely_missing_cli_key'
|
||||
|
||||
|
||||
def test_dangerous_command_rejection_text_output_is_human_readable(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=text')
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\n'
|
||||
'message: 已取消危险命令执行:db revision\n'
|
||||
'hint: 当前命令需要交互确认;如需非交互执行,请传入 --yes\n'
|
||||
)
|
||||
|
||||
|
||||
def test_dry_run_text_output_contains_preview_command_and_workdir(
|
||||
backend_dir: Path,
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'db',
|
||||
'upgrade',
|
||||
'--env=dev',
|
||||
'--revision=head',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'message: 数据库已升级到 head(dry-run)\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'command:\n - alembic\n - -c\n' in completed.stdout
|
||||
assert f' - {backend_dir / "alembic.ini"}\n' in completed.stdout
|
||||
assert f'cwd: {backend_dir}\n' in completed.stdout
|
||||
|
||||
|
||||
def test_dev_lint_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'dev',
|
||||
'lint',
|
||||
'cli/groups/dev/command.py',
|
||||
'--env=dev',
|
||||
'--check-only',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['message'] == '开发检查已完成'
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['checkOnly'] is True
|
||||
assert payload['fix'] is False
|
||||
assert payload['unsafeFixes'] is False
|
||||
assert payload['targets'] == ['cli/groups/dev/command.py']
|
||||
assert isinstance(payload['format'], dict)
|
||||
assert isinstance(payload['check'], dict)
|
||||
assert payload['format']['ok'] is True
|
||||
assert payload['check']['ok'] is True
|
||||
assert isinstance(payload['format']['command'], list)
|
||||
assert isinstance(payload['check']['command'], list)
|
||||
|
||||
|
||||
def test_dev_test_text_output_has_stable_summary(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'dev',
|
||||
'test',
|
||||
'tests/cli/root/test_contract_cli.py',
|
||||
'--env=dev',
|
||||
'--keyword=test_root_help_shows_commands_without_completion_options',
|
||||
'--maxfail=1',
|
||||
'--quiet',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'ok: true\n' in completed.stdout
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'keyword: test_root_help_shows_commands_without_completion_options\n' in completed.stdout
|
||||
assert 'maxfail: 1\n' in completed.stdout
|
||||
assert 'quiet: true\n' in completed.stdout
|
||||
assert 'targets:\n - tests/cli/root/test_contract_cli.py\n' in completed.stdout
|
||||
assert 'test:\n ok: true\n' in completed.stdout
|
||||
assert 'command: ' in completed.stdout
|
||||
@@ -0,0 +1,368 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
GEN_TEST_TABLE_NAME = 'demo_table'
|
||||
GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);'
|
||||
MISSING_JOB_ID = 999999999
|
||||
MISSING_GEN_TABLE_ID = 999999999
|
||||
|
||||
|
||||
def test_job_detail_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
list_completed = run_cli_command('job', 'list', '--env=dev', '--output=json')
|
||||
list_payload = parse_json_stdout(list_completed)
|
||||
job_id = MISSING_JOB_ID
|
||||
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
|
||||
first_job = list_payload['items'][0]
|
||||
if isinstance(first_job, dict) and isinstance(first_job.get('jobId'), int):
|
||||
job_id = first_job['jobId']
|
||||
|
||||
completed = run_cli_command('job', 'detail', str(job_id), '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
if payload['ok'] is False:
|
||||
assert set(payload) == {'ok', 'message', 'jobId'}
|
||||
assert payload['jobId'] == job_id
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'job'}
|
||||
assert isinstance(payload['job'], dict)
|
||||
assert payload['job']['jobId'] == job_id
|
||||
assert isinstance(payload['job'].get('jobName'), str)
|
||||
assert isinstance(payload['job'].get('invokeTarget'), str)
|
||||
|
||||
|
||||
def test_job_logs_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('job', 'logs', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('jobLogId'), int)
|
||||
assert isinstance(first_item.get('jobName'), str | None)
|
||||
assert isinstance(first_item.get('jobMessage'), str | None)
|
||||
|
||||
|
||||
def test_gen_list_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('tableId'), int)
|
||||
assert isinstance(first_item.get('tableName'), str)
|
||||
|
||||
|
||||
def test_gen_db_list_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'db-list', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('tableName'), str)
|
||||
|
||||
|
||||
def test_gen_detail_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
list_completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
|
||||
list_payload = parse_json_stdout(list_completed)
|
||||
table_id = MISSING_GEN_TABLE_ID
|
||||
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
|
||||
first_item = list_payload['items'][0]
|
||||
if isinstance(first_item, dict) and isinstance(first_item.get('tableId'), int):
|
||||
table_id = first_item['tableId']
|
||||
|
||||
completed = run_cli_command('gen', 'detail', str(table_id), '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
if payload['ok'] is False:
|
||||
assert set(payload) == {'ok', 'message', 'tableId', 'env'}
|
||||
assert payload['tableId'] == table_id
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'tableId', 'tableName', 'columnCount', 'tableCount', 'detail', 'env'}
|
||||
assert payload['tableId'] == table_id
|
||||
assert isinstance(payload['tableName'], str)
|
||||
assert isinstance(payload['columnCount'], int)
|
||||
assert isinstance(payload['tableCount'], int)
|
||||
assert isinstance(payload['detail'], dict)
|
||||
assert isinstance(payload['detail'].get('info'), dict)
|
||||
assert isinstance(payload['detail'].get('rows'), list)
|
||||
assert isinstance(payload['detail'].get('tables'), list)
|
||||
|
||||
|
||||
def test_gen_sync_db_is_rejected_without_yes_in_non_interactive_mode(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'sync-db', 'demo_table', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '已取消危险命令执行:gen sync-db'
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_gen_export_dry_run_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'export',
|
||||
GEN_TEST_TABLE_NAME,
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'mode: zip\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'message: 代码导出演练完成,未执行实际导出\n' in completed.stdout
|
||||
assert 'table_names:\n' in completed.stdout
|
||||
assert f' - {GEN_TEST_TABLE_NAME}\n' in completed.stdout
|
||||
assert 'output_file:' in completed.stdout
|
||||
assert f'gen_code_{GEN_TEST_TABLE_NAME}.zip' in completed.stdout
|
||||
|
||||
|
||||
def test_gen_export_dry_run_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'export',
|
||||
GEN_TEST_TABLE_NAME,
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['mode'] == 'zip'
|
||||
assert payload['message'] == '代码导出演练完成,未执行实际导出'
|
||||
assert payload['tableNames'] == [GEN_TEST_TABLE_NAME]
|
||||
assert isinstance(payload['outputFile'], str)
|
||||
assert payload['outputFile'].endswith(f'gen_code_{GEN_TEST_TABLE_NAME}.zip')
|
||||
|
||||
|
||||
def test_gen_create_table_dry_run_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'message: 建表语句演练完成,未执行实际建表\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'statement_count: 1\n' in completed.stdout
|
||||
assert 'table_names:\n' in completed.stdout
|
||||
assert ' - demo_cli_test\n' in completed.stdout
|
||||
assert f'sql: {GEN_CREATE_SQL}\n' in completed.stdout
|
||||
|
||||
|
||||
def test_gen_create_table_dry_run_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['message'] == '建表语句演练完成,未执行实际建表'
|
||||
assert payload['statementCount'] == 1
|
||||
assert payload['tableNames'] == ['demo_cli_test']
|
||||
assert payload['sql'] == GEN_CREATE_SQL
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_non_create_sql_in_text_output(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
'DROP TABLE demo_cli_test;',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 建表语句不合法,仅允许 CREATE TABLE 语句\nenv: dev\n'
|
||||
)
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_non_create_sql_in_json_output(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
'DROP TABLE demo_cli_test;',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': '创建表结构失败',
|
||||
'error': '建表语句不合法,仅允许 CREATE TABLE 语句',
|
||||
'env': 'dev',
|
||||
}
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_conflicting_sql_inputs_in_text_output(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--sql-file',
|
||||
'fake.sql',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 必须且只能传入 --sql 或 --sql-file 其中一种方式\nenv: dev\n'
|
||||
)
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_conflicting_sql_inputs_in_json_output(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--sql-file',
|
||||
'fake.sql',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': '创建表结构失败',
|
||||
'error': '必须且只能传入 --sql 或 --sql-file 其中一种方式',
|
||||
'env': 'dev',
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
cli_guards = importlib.import_module('cli.guards')
|
||||
cli_guards = importlib.reload(cli_guards)
|
||||
|
||||
DEFAULT_DANGEROUS_COMMAND_RULES = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULES
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
|
||||
|
||||
|
||||
def test_dangerous_command_rules_cover_expected_commands() -> None:
|
||||
"""
|
||||
校验危险命令规则表已覆盖当前设计文档中约定的命令范围。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
assert set(DEFAULT_DANGEROUS_COMMAND_RULES) == {
|
||||
'cache clear',
|
||||
'cache warmup',
|
||||
'db upgrade',
|
||||
'db init',
|
||||
'db downgrade',
|
||||
'db revision',
|
||||
'config set',
|
||||
'config sync-cache',
|
||||
'crypto rotate',
|
||||
'job run-once',
|
||||
'job pause',
|
||||
'job resume',
|
||||
'job sync',
|
||||
'gen import-table',
|
||||
'gen create-table',
|
||||
'gen export',
|
||||
'gen sync-db',
|
||||
}
|
||||
|
||||
|
||||
def test_dangerous_command_rules_expose_risk_level_and_dry_run_metadata() -> None:
|
||||
"""
|
||||
校验危险命令规则能够提供风险级别和 dry-run 能力元数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
db_upgrade_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('db upgrade')
|
||||
cache_warmup_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('cache warmup')
|
||||
|
||||
assert db_upgrade_rule is not None
|
||||
assert db_upgrade_rule.risk_level == 'high'
|
||||
assert db_upgrade_rule.supports_dry_run is True
|
||||
|
||||
assert cache_warmup_rule is not None
|
||||
assert cache_warmup_rule.risk_level == 'normal'
|
||||
assert cache_warmup_rule.supports_dry_run is False
|
||||
|
||||
|
||||
def test_require_dangerous_command_rule_rejects_unknown_command() -> None:
|
||||
"""
|
||||
校验未注册的危险命令名称会被显式拒绝。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
with pytest.raises(ValueError, match='危险命令未注册保护规则'):
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('unknown dangerous command')
|
||||
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def test_config_doctor_json_stdout_is_not_polluted_by_sqlalchemy_logs(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
"""
|
||||
校验 JSON 输出模式下不会混入 SQLAlchemy engine 日志。
|
||||
|
||||
:param run_cli_command: CLI 子进程执行器
|
||||
:return: None
|
||||
"""
|
||||
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
|
||||
|
||||
assert 'sqlalchemy.engine.Engine' not in completed.stdout
|
||||
assert 'SELECT DATABASE()' not in completed.stdout
|
||||
assert completed.stdout.lstrip().startswith('{')
|
||||
@@ -0,0 +1,13 @@
|
||||
import importlib
|
||||
|
||||
|
||||
def test_cli_main_runner_reuses_single_project_runtime_locator() -> None:
|
||||
"""
|
||||
校验 CLI 根运行器与导入期参数作用域共享同一个项目定位器实例。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
cli_main = importlib.import_module('cli.main')
|
||||
|
||||
assert cli_main.CLI_MAIN_RUNNER.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
|
||||
assert cli_main.CLI_MAIN_RUNNER.import_argv_scope.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
|
||||
@@ -0,0 +1,181 @@
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.output', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
cli_output = importlib.import_module('cli.output')
|
||||
OUTPUT_RENDERER = cli_output.OutputRenderer()
|
||||
|
||||
_ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*m')
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""
|
||||
模拟终端输出流。
|
||||
|
||||
:param is_tty: 是否模拟为 TTY
|
||||
"""
|
||||
|
||||
def __init__(self, *, is_tty: bool) -> None:
|
||||
self._is_tty = is_tty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
"""
|
||||
返回是否为 TTY。
|
||||
|
||||
:return: 是否为 TTY
|
||||
"""
|
||||
return self._is_tty
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""
|
||||
移除 ANSI 转义序列,便于断言文本可视结果。
|
||||
|
||||
:param text: 原始带样式文本
|
||||
:return: 去除 ANSI 后的文本
|
||||
"""
|
||||
return _ANSI_ESCAPE_PATTERN.sub('', text)
|
||||
|
||||
|
||||
def test_decorate_text_output_promotes_success_header_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验成功结果头在 emoji 模式下会被提升为统一状态头。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
|
||||
rendered = OUTPUT_RENDERER.decorate_text_output('ok: true\nenv: dev')
|
||||
|
||||
assert rendered == '✅ OK SUCCESS\nenv: dev'
|
||||
|
||||
|
||||
def test_decorate_text_output_promotes_failure_header_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验失败结果头在 emoji 模式下会被提升为统一状态头。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
|
||||
rendered = OUTPUT_RENDERER.decorate_text_output('ok: false\nerror: boom')
|
||||
|
||||
assert rendered == '❌ FAIL FAILED\nerror: boom'
|
||||
|
||||
|
||||
def test_colorize_text_output_renders_inline_status_segments_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验文本着色后会为 message、hint、error 等字段附加状态标签。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nmessage: retry later\nhint: use --yes\nerror: boom')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert '\x1b[' in rendered
|
||||
assert _strip_ansi(rendered) == (
|
||||
'❌ FAIL FAILED\nmessage: 💬 INFO retry later\nhint: 💡 HINT use --yes\nerror: 🚨 ERROR boom'
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_text_output_supports_warning_and_info_field_aliases() -> None:
|
||||
"""
|
||||
校验 `warning` 与 `info` 字段也会使用统一状态图标渲染。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nwarning: rotate key soon\ninfo: cache refreshed')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert _strip_ansi(rendered) == ('✅ OK SUCCESS\nwarning: 💡 HINT rotate key soon\ninfo: 💬 INFO cache refreshed')
|
||||
|
||||
|
||||
def test_colorize_text_output_styles_status_segments_inside_list_items() -> None:
|
||||
"""
|
||||
校验列表项中的带标签状态片段也会应用统一视觉规则。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(
|
||||
'results:\n - users: true | exported\n - roles: false | error: lock timeout',
|
||||
_FakeStream(is_tty=True),
|
||||
)
|
||||
|
||||
assert _strip_ansi(rendered) == (
|
||||
'results:\n - users: ✅ OK | exported\n - roles: ❌ FAIL | 🚨 ERROR lock timeout'
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_text_output_supports_ascii_icon_mode() -> None:
|
||||
"""
|
||||
校验 ascii 图标模式会渲染为纯 ASCII 状态标签。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('ascii')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nhint: use --yes')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert _strip_ansi(rendered) == '[OK] SUCCESS\nhint: [HINT] use --yes'
|
||||
|
||||
|
||||
def test_colorize_text_output_skips_ansi_when_color_mode_is_never() -> None:
|
||||
"""
|
||||
校验关闭颜色后不会输出 ANSI 转义序列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('never')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nhint: use --yes')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert rendered == '❌ FAIL FAILED\nhint: use --yes'
|
||||
assert '\x1b[' not in rendered
|
||||
|
||||
|
||||
def test_render_error_text_uses_status_token_in_emoji_mode() -> None:
|
||||
"""
|
||||
校验标准错误输出在 emoji 模式下使用统一错误图标。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('never')
|
||||
|
||||
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
|
||||
|
||||
assert rendered == '🚨 ERROR[23]: boom'
|
||||
|
||||
|
||||
def test_render_error_text_supports_ascii_mode_with_color() -> None:
|
||||
"""
|
||||
校验标准错误输出在 ascii 模式下仍保持统一错误前缀与颜色。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('ascii')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
|
||||
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
|
||||
|
||||
assert '\x1b[' in rendered
|
||||
assert _strip_ansi(rendered) == '[ERROR][23]: boom'
|
||||
@@ -0,0 +1,142 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.utils', None)
|
||||
cli_utils = importlib.import_module('cli.utils')
|
||||
|
||||
|
||||
def test_run_nested_cli_command_extracts_json_from_noisy_stdout(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用可从带噪声的标准输出中提取 JSON 负载。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='debug line before json\n{"ok": true, "message": "success"}\ndebug line after json\n',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload == {'ok': True, 'message': 'success'}
|
||||
|
||||
|
||||
def test_run_nested_cli_command_extracts_first_json_object_from_mixed_stdout(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用可从混杂文本中提取首个合法 JSON 对象。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='preface\ntrace {not-json}\n{"ok": true, "message": "job done"}\ntrailer\n',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload == {'ok': True, 'message': 'job done'}
|
||||
|
||||
|
||||
def test_run_nested_cli_command_falls_back_to_text_payload_when_json_missing(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验内部 CLI 未返回 JSON 时会回退为文本结果负载。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='任务已提交到调度器',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload is not None
|
||||
assert result.payload.get('ok') is True
|
||||
assert result.payload.get('message') == '任务已提交到调度器'
|
||||
assert result.payload.get('fallback') == 'non_json_output'
|
||||
|
||||
|
||||
def test_run_nested_cli_command_uses_backend_dir_and_pythonpath(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用会固定后端工作目录并注入项目路径。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
recorded_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args
|
||||
recorded_kwargs.update(kwargs)
|
||||
return SimpleNamespace(stdout='{}', stderr='', returncode=0)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', parse_json=False)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
|
||||
process_env = recorded_kwargs['env']
|
||||
assert isinstance(process_env, dict)
|
||||
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
|
||||
|
||||
|
||||
def test_run_nested_cli_command_live_uses_backend_dir_without_capturing_output(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验交互式内部 CLI 调用会复用后端工作目录并直接占用当前终端。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
recorded_kwargs: dict[str, object] = {}
|
||||
recorded_args: tuple[object, ...] = ()
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
nonlocal recorded_args
|
||||
recorded_args = args
|
||||
recorded_kwargs.update(kwargs)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
completed = cli_utils.NESTED_CLI_SUPPORT.run_live('wizard', 'cache-clear', '--output=text')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert recorded_args
|
||||
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
|
||||
process_env = recorded_kwargs['env']
|
||||
assert isinstance(process_env, dict)
|
||||
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
|
||||
assert 'capture_output' not in recorded_kwargs
|
||||
Reference in New Issue
Block a user