初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -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