Initial commit: add AutoDeploy project

This commit is contained in:
wangpeng
2026-07-14 16:30:36 +08:00
commit 2d7b6216ba
23 changed files with 3615 additions and 0 deletions

674
tests/test_deployer.py Normal file
View File

@@ -0,0 +1,674 @@
import unittest
import os
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from deploy_tool.deployer import DeploymentConfig, DeploymentStep, build_deployment_plan, run_steps, resolve_process_command
import deploy_tool.deployer as deployer_module
from deploy_tool.scanner import ProjectComponent, ScanResult
class DeployerTests(unittest.TestCase):
def test_extracts_backend_port_from_java_system_property(self):
extract_port = getattr(deployer_module, "_extract_server_port", lambda command: None)
port = extract_port(["java", "-Dserver.port=9090", "-jar", "app.jar"])
self.assertEqual(port, 9090)
def test_rejects_backend_port_outside_valid_tcp_range(self):
for value in ("0", "-1", "65536"):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "1 到 65535"):
deployer_module._extract_server_port(["java", f"-Dserver.port={value}", "-jar", "app.jar"])
def test_parses_ipv4_and_ipv6_listeners_for_configured_port(self):
parse_listeners = getattr(deployer_module, "_parse_windows_listening_pids", lambda output, port: set())
output = """
TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 27544
TCP [::]:8080 [::]:0 LISTENING 27544
TCP 127.0.0.1:8080 127.0.0.1:52100 ESTABLISHED 30000
TCP 0.0.0.0:9090 0.0.0.0:0 LISTENING 31000
"""
pids = parse_listeners(output, 8080)
self.assertEqual(pids, {27544})
def test_port_release_waits_until_no_interface_has_a_listener(self):
listener_snapshots = iter(({27544}, set()))
with (
patch.object(deployer_module, "_find_listening_pids", side_effect=lambda port: next(listener_snapshots)) as find_listeners,
patch.object(deployer_module, "_is_port_open", return_value=False),
patch.object(deployer_module.time, "sleep", return_value=None),
):
released = deployer_module._wait_until_port_closed(8080)
self.assertTrue(released)
self.assertEqual(find_listeners.call_count, 2)
def test_replaces_all_processes_listening_on_backend_port(self):
replace_listeners = getattr(deployer_module, "_replace_port_listeners", lambda *args, **kwargs: False)
terminated = []
events = []
released = replace_listeners(
8080,
events.append,
find_listeners=lambda port: {27544, 23780},
terminate=lambda pid: terminated.append(pid),
wait_until_closed=lambda port: True,
)
self.assertTrue(released)
self.assertEqual(set(terminated), {27544, 23780})
self.assertTrue(any("8080" in event and "27544" in event for event in events))
def test_port_replacement_skips_pid_removed_by_an_earlier_tree_kill(self):
replace_listeners = getattr(deployer_module, "_replace_port_listeners", lambda *args, **kwargs: False)
active_pids = {23780, 27544}
terminated = []
def terminate_tree(pid):
terminated.append(pid)
active_pids.clear()
released = replace_listeners(
8080,
lambda value: None,
find_listeners=lambda port: set(active_pids),
terminate=terminate_tree,
wait_until_closed=lambda port: True,
)
self.assertTrue(released)
self.assertEqual(terminated, [23780])
def test_backend_restart_fails_before_spawn_when_port_is_not_released(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
spawned = []
with TemporaryDirectory() as temp:
exit_code = restart_backend(
["java", "-Dserver.port=8080", "-jar", "app.jar"],
Path(temp),
lambda value: None,
replace_listeners=lambda port, on_output: False,
popen=lambda *args, **kwargs: spawned.append(args),
)
self.assertEqual(exit_code, 1)
self.assertEqual(spawned, [])
def test_backend_restart_returns_child_exit_code_when_java_exits_early(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
class ExitedProcess:
pid = 23780
def poll(self):
return 2
with TemporaryDirectory() as temp:
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
Path(temp),
lambda value: None,
replace_listeners=lambda port, on_output: True,
popen=lambda *args, **kwargs: ExitedProcess(),
)
self.assertEqual(exit_code, 2)
def test_backend_restart_reports_success_only_after_port_is_reachable(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
checked_ports = []
logs = []
class RunningProcess:
pid = 27544
def poll(self):
return None
def port_is_open(port):
checked_ports.append(port)
return True
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", side_effect=port_is_open),
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
):
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
Path(temp),
logs.append,
replace_listeners=lambda port, on_output: True,
popen=lambda *args, **kwargs: RunningProcess(),
)
self.assertEqual(exit_code, 0)
self.assertEqual(checked_ports, [65530])
self.assertTrue(any("27544" in line and "启动成功" in line for line in logs))
def test_backend_restart_rejects_reachable_port_owned_by_another_process(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
terminated = []
class RunningProcess:
pid = 27544
def poll(self):
return None
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", return_value=True),
patch.object(deployer_module, "_find_listening_pids", return_value={31000}),
patch.object(deployer_module, "_terminate_process_tree", side_effect=lambda pid: terminated.append(pid)),
patch.object(deployer_module.time, "monotonic", side_effect=[0.0, 0.0, 61.0]),
):
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
Path(temp),
lambda value: None,
replace_listeners=lambda port, on_output: True,
popen=lambda *args, **kwargs: RunningProcess(),
)
self.assertEqual(exit_code, 1)
self.assertEqual(terminated, [27544])
def test_backend_restart_terminates_child_after_startup_timeout(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
terminated = []
class RunningProcess:
pid = 27544
def poll(self):
return None
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", return_value=False),
patch.object(deployer_module, "_terminate_process_tree", side_effect=lambda pid: terminated.append(pid)),
patch.object(deployer_module.time, "monotonic", side_effect=[0.0, 61.0]),
):
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
Path(temp),
lambda value: None,
replace_listeners=lambda port, on_output: True,
popen=lambda *args, **kwargs: RunningProcess(),
)
self.assertEqual(exit_code, 1)
self.assertEqual(terminated, [27544])
@unittest.skipUnless(os.name == "nt", "Windows creation flags only apply on Windows")
def test_backend_restart_uses_visible_windows_console(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
popen_options = {}
class RunningProcess:
pid = 27544
def poll(self):
return None
def fake_popen(*args, **kwargs):
popen_options.update(kwargs)
return RunningProcess()
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", return_value=True),
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
):
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
Path(temp),
lambda value: None,
replace_listeners=lambda port, on_output: True,
popen=fake_popen,
)
self.assertEqual(exit_code, 0)
self.assertIn("creationflags", popen_options)
self.assertTrue(popen_options["creationflags"] & subprocess.CREATE_NEW_CONSOLE)
self.assertTrue(popen_options["creationflags"] & subprocess.CREATE_NEW_PROCESS_GROUP)
def test_background_runner_returns_backend_restart_health_result(self):
class FakeProcess:
pid = 27544
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_restart_backend", return_value=7) as restart_backend,
patch.object(deployer_module.subprocess, "Popen", return_value=FakeProcess()),
):
exit_code = deployer_module._run_process(
["java", "-Dserver.port=8080", "-jar", "app.jar"],
Path(temp),
lambda value: None,
background=True,
)
self.assertEqual(exit_code, 7)
restart_backend.assert_called_once()
def test_backend_restart_separates_each_run_in_backend_log(self):
restart_backend = getattr(deployer_module, "_restart_backend", lambda *args, **kwargs: 0)
class RunningProcess:
pid = 27544
def poll(self):
return None
with (
TemporaryDirectory() as temp,
patch.object(deployer_module, "_is_port_open", return_value=True),
patch.object(deployer_module, "_find_listening_pids", return_value={27544}),
):
root = Path(temp)
exit_code = restart_backend(
["java", "-Dserver.port=65530", "-jar", "app.jar"],
root,
lambda value: None,
replace_listeners=lambda port, on_output: True,
popen=lambda *args, **kwargs: RunningProcess(),
)
log_text = (root / "backend.log").read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertIn("后端部署", log_text)
def test_builds_plan_for_detected_components(self):
root = Path("C:/project")
scan = ScanResult(
root=root,
backend=ProjectComponent("Spring Boot", root / "server", "Maven project"),
frontend=ProjectComponent(
"Vue",
root / "web",
"Vue project",
{"role": "user", "display_name": "用户端", "start_script": "serve"},
),
sql_files=[root / "db" / "schema.sql"],
)
config = DeploymentConfig(
mysql_user="root",
mysql_password="secret",
mysql_database="demo",
backend_port="8080",
output_dir=root / "deploy",
)
plan = build_deployment_plan(scan, config)
self.assertEqual(
[step.kind for step in plan],
[
"backend_build",
"frontend_install",
"sql_import",
"backend_start",
"frontend_start",
"deployment_summary",
],
)
self.assertIn("mvn", plan[0].command[0])
self.assertEqual(plan[2].cwd, root)
def test_builds_and_starts_user_and_admin_frontends_then_logs_all_addresses(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "server"
client = root / "client_code"
admin = root / "manage_code"
(backend / "target").mkdir(parents=True)
(backend / "target" / "app.jar").write_text("jar", encoding="utf-8")
frontends = []
for path, role, display_name, port in (
(client, "user", "用户端", "8082"),
(admin, "admin", "管理端", "8081"),
):
(path / "node_modules").mkdir(parents=True)
(path / "dist").mkdir()
(path / "dist" / "index.html").write_text("<main>app</main>", encoding="utf-8")
(path / "package.json").write_text(
'{"scripts":{"build":"vue-cli-service build","serve":"vue-cli-service serve"}}',
encoding="utf-8",
)
frontends.append(
ProjectComponent(
"Vue",
path,
"Vue project",
{"role": role, "display_name": display_name, "port": port, "start_script": "serve"},
)
)
scan = ScanResult(
root=root,
backend=ProjectComponent(
"Spring Boot", backend, "Maven project", {"context_path": "/movies"}
),
frontend=frontends[0],
sql_files=[],
frontends=frontends,
)
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy"))
self.assertEqual(
[step.kind for step in plan],
[
"backend_build_skip",
"backend_start",
"frontend_start",
"frontend_start",
"deployment_summary",
],
)
start_steps = [step for step in plan if step.kind == "frontend_start"]
self.assertEqual([step.port for step in start_steps], [8082, 8081])
self.assertEqual([step.service_name for step in start_steps], ["用户端", "管理端"])
self.assertEqual(start_steps[0].output_path, root / "deploy" / "frontend-user.log")
self.assertEqual(start_steps[1].output_path, root / "deploy" / "frontend-admin.log")
logs = []
exit_code = run_steps(plan, logs.append, process_runner=lambda *args: 0)
self.assertEqual(exit_code, 0)
combined = "\n".join(logs)
self.assertIn("后端: http://localhost:8080/movies", combined)
self.assertIn("用户端: http://localhost:8082/", combined)
self.assertIn("管理端: http://localhost:8081/", combined)
def test_plan_uses_executable_steps_without_shell_placeholders(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "server"
frontend = root / "web"
database = root / "db"
backend.mkdir()
frontend.mkdir()
database.mkdir()
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
(frontend / "package.json").write_text(
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
)
sql_file = database / "schema.sql"
sql_file.write_text("create table demo(id int);", encoding="utf-8")
scan = ScanResult(
root=root,
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
frontend=ProjectComponent("Vue", frontend, "Vue project"),
sql_files=[sql_file],
)
config = DeploymentConfig(mysql_database="demo", output_dir=root / "deploy")
plan = build_deployment_plan(scan, config)
self.assertNotIn("frontend_build", [step.kind for step in plan])
self.assertNotIn("frontend_copy", [step.kind for step in plan])
for step in plan:
joined = " ".join(step.command)
self.assertNotIn("<", joined)
self.assertNotIn(">", joined)
sql_step = next(step for step in plan if step.kind == "sql_import")
self.assertEqual(sql_step.input_path, sql_file)
self.assertNotIn(str(sql_file), sql_step.command)
def test_run_steps_installs_frontend_imports_sql_and_starts_services(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "server"
frontend = root / "web"
target = backend / "target"
database = root / "db"
target.mkdir(parents=True)
frontend.mkdir(parents=True)
database.mkdir()
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
(frontend / "package.json").write_text(
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
)
old_jar = target / "old.jar"
app_jar = target / "app.jar"
old_jar.write_text("old", encoding="utf-8")
app_jar.write_text("new", encoding="utf-8")
os.utime(old_jar, (1, 1))
os.utime(app_jar, (2, 2))
sql_file = database / "schema.sql"
sql_file.write_text("create table demo(id int);", encoding="utf-8")
scan = ScanResult(
root=root,
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
frontend=ProjectComponent("Vue", frontend, "Vue project"),
sql_files=[sql_file],
)
config = DeploymentConfig(
mysql_user="root",
mysql_password="secret",
mysql_database="demo",
backend_port="9090",
output_dir=root / "deploy",
tool_search_roots=[root / "missing"],
)
plan = build_deployment_plan(scan, config)
calls = []
def fake_runner(command, cwd, on_output, input_path=None, background=False):
calls.append((command, cwd, input_path, background))
return 0
exit_code = run_steps(plan, lambda value: None, process_runner=fake_runner)
self.assertEqual(exit_code, 0)
self.assertIn((["mysql", "-u", "root", "-psecret", "demo"], root, sql_file, False), calls)
self.assertIn((["java", "-Dserver.port=9090", "-jar", str(app_jar)], root / "deploy", None, True), calls)
def test_plan_uses_configured_maven_path_for_backend_build(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "server"
maven = root / "tools" / "apache-maven" / "bin" / "mvn.cmd"
backend.mkdir()
maven.parent.mkdir(parents=True)
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
maven.write_text("", encoding="utf-8")
scan = ScanResult(
root=root,
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
frontend=None,
sql_files=[],
)
config = DeploymentConfig(tool_paths={"Maven": maven})
plan = build_deployment_plan(scan, config)
self.assertEqual(plan[0].command, [str(maven), "clean", "package", "-DskipTests"])
def test_plan_uses_discovered_mysql_path_for_sql_import(self):
with TemporaryDirectory() as temp:
root = Path(temp)
mysql = root / "MySQL" / "MySQL Server 8.0" / "bin" / "mysql.exe"
database = root / "db"
mysql.parent.mkdir(parents=True)
database.mkdir()
mysql.write_text("", encoding="utf-8")
sql_file = database / "schema.sql"
sql_file.write_text("create table demo(id int);", encoding="utf-8")
scan = ScanResult(
root=root,
backend=None,
frontend=None,
sql_files=[sql_file],
)
config = DeploymentConfig(mysql_database="demo", tool_search_roots=[root])
plan = build_deployment_plan(scan, config)
self.assertEqual(plan[0].command, [str(mysql), "-u", "root", "demo"])
def test_sql_dump_with_database_selection_does_not_require_existing_database(self):
with TemporaryDirectory() as temp:
root = Path(temp)
database = root / "db"
database.mkdir()
sql_file = database / "movie.sql"
sql_file.write_text(
"CREATE DATABASE IF NOT EXISTS `movie`;\nUSE `movie`;\nCREATE TABLE demo(id int);",
encoding="utf-8",
)
scan = ScanResult(
root=root,
backend=None,
frontend=None,
sql_files=[sql_file],
)
config = DeploymentConfig(mysql_database="movie", tool_search_roots=[root / "missing"])
plan = build_deployment_plan(scan, config)
self.assertEqual(plan[0].command, ["mysql", "-u", "root"])
def test_plan_skips_backend_build_when_runnable_jar_already_exists(self):
with TemporaryDirectory() as temp:
root = Path(temp)
backend = root / "server"
target = backend / "target"
target.mkdir(parents=True)
(backend / "pom.xml").write_text("<project />", encoding="utf-8")
(target / "app.jar").write_text("jar", encoding="utf-8")
scan = ScanResult(
root=root,
backend=ProjectComponent("Spring Boot", backend, "Maven project"),
frontend=None,
sql_files=[],
)
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
self.assertEqual([step.kind for step in plan], ["backend_build_skip", "backend_start", "deployment_summary"])
self.assertEqual(plan[0].command, [])
self.assertIn("已有后端 jar", plan[0].description)
def test_existing_dist_does_not_add_frontend_build_or_copy_steps(self):
with TemporaryDirectory() as temp:
root = Path(temp)
frontend = root / "web"
dist = frontend / "dist"
dist.mkdir(parents=True)
(frontend / "package.json").write_text(
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
)
(dist / "index.html").write_text("<main>built</main>", encoding="utf-8")
scan = ScanResult(
root=root,
backend=None,
frontend=ProjectComponent("Vue", frontend, "Vue project"),
sql_files=[],
)
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
self.assertEqual(
[step.kind for step in plan],
["frontend_install", "frontend_start", "deployment_summary"],
)
self.assertNotIn("frontend_build", [step.kind for step in plan])
self.assertNotIn("frontend_copy", [step.kind for step in plan])
def test_run_steps_logs_skip_steps_without_running_a_process(self):
with TemporaryDirectory() as temp:
root = Path(temp)
logs = []
calls = []
skip_step = DeploymentStep("跳过构建", [], root, "检测到已有产物,跳过构建", kind="skip")
def fake_runner(command, cwd, on_output, input_path=None, background=False):
calls.append(command)
return 0
exit_code = run_steps([skip_step], logs.append, process_runner=fake_runner)
self.assertEqual(exit_code, 0)
self.assertEqual(calls, [])
self.assertTrue(any("检测到已有产物" in line for line in logs))
def test_plan_uses_configured_npm_path_for_frontend_start(self):
with TemporaryDirectory() as temp:
root = Path(temp)
frontend = root / "web"
npm = root / "nodejs" / "npm.cmd"
frontend.mkdir()
(frontend / "node_modules").mkdir()
npm.parent.mkdir(parents=True)
(frontend / "package.json").write_text(
'{"scripts":{"build":"vite build","dev":"vite"}}', encoding="utf-8"
)
npm.write_text("", encoding="utf-8")
scan = ScanResult(
root=root,
backend=None,
frontend=ProjectComponent("Vue", frontend, "Vue project"),
sql_files=[],
)
config = DeploymentConfig(tool_paths={"npm": npm})
plan = build_deployment_plan(scan, config)
self.assertEqual(
plan[0].command,
[str(npm), "run", "dev", "--", "--host", "0.0.0.0", "--port", "8081"],
)
def test_plan_installs_frontend_dependencies_before_start_when_node_modules_missing(self):
with TemporaryDirectory() as temp:
root = Path(temp)
frontend = root / "web"
frontend.mkdir()
(frontend / "package.json").write_text(
'{"scripts":{"build":"vue-cli-service build","serve":"vue-cli-service serve"},'
'"devDependencies":{"@vue/cli-service":"^5.0.0"}}',
encoding="utf-8",
)
scan = ScanResult(
root=root,
backend=None,
frontend=ProjectComponent("Vue", frontend, "Vue project"),
sql_files=[],
)
plan = build_deployment_plan(scan, DeploymentConfig(output_dir=root / "deploy", tool_search_roots=[root / "missing"]))
npm = "npm.cmd" if os.name == "nt" else "npm"
self.assertEqual(
[step.kind for step in plan],
["frontend_install", "frontend_start", "deployment_summary"],
)
self.assertEqual(plan[0].command, [npm, "install"])
self.assertEqual(plan[1].command[1:3], ["run", "serve"])
def test_resolves_windows_cmd_shims_before_starting_process(self):
with TemporaryDirectory() as temp:
root = Path(temp)
npm = root / "npm.cmd"
npm.write_text("", encoding="utf-8")
command = resolve_process_command(["npm", "run", "build"], path=str(root))
self.assertEqual(os.path.normcase(command[0]), os.path.normcase(str(npm)))
self.assertEqual(command[1:], ["run", "build"])
if __name__ == "__main__":
unittest.main()

103
tests/test_environment.py Normal file
View File

@@ -0,0 +1,103 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
from deploy_tool.environment import check_environment, load_tool_paths, save_tool_paths
class EnvironmentTests(unittest.TestCase):
def test_reports_installed_command(self):
def runner(command):
return subprocess.CompletedProcess(command, 0, "java 21\n", "")
results = check_environment(runner=runner, checks={"JDK": ["java", "-version"]})
self.assertTrue(results[0].installed)
self.assertEqual(results[0].name, "JDK")
self.assertEqual(results[0].source, "PATH")
self.assertIn("java 21", results[0].version)
def test_reports_missing_command(self):
def runner(command):
raise FileNotFoundError(command[0])
results = check_environment(runner=runner, checks={"MySQL": ["mysql", "--version"]})
self.assertFalse(results[0].installed)
self.assertEqual(results[0].name, "MySQL")
self.assertIn("未安装", results[0].message)
def test_uses_manual_path_when_command_is_not_in_path(self):
with tempfile.TemporaryDirectory() as tmp:
mysql = Path(tmp) / "mysql.exe"
mysql.write_text("", encoding="utf-8")
def runner(command):
if command[0] == "mysql":
raise FileNotFoundError(command[0])
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
results = check_environment(
runner=runner,
checks={"MySQL": ["mysql", "--version"]},
manual_paths={"MySQL": mysql},
)
self.assertTrue(results[0].installed)
self.assertEqual(results[0].source, "手动指定")
self.assertEqual(results[0].command[0], str(mysql))
def test_discovers_executable_from_known_search_paths(self):
with tempfile.TemporaryDirectory() as tmp:
mysql = Path(tmp) / "MySQL" / "MySQL Server 8.4" / "bin" / "mysql.exe"
mysql.parent.mkdir(parents=True)
mysql.write_text("", encoding="utf-8")
def runner(command):
if command[0] == "mysql":
raise FileNotFoundError(command[0])
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
results = check_environment(
runner=runner,
checks={"MySQL": ["mysql", "--version"]},
search_roots=[Path(tmp)],
)
self.assertTrue(results[0].installed)
self.assertEqual(results[0].source, "自动发现")
self.assertEqual(results[0].command[0], str(mysql))
def test_does_not_recursively_scan_arbitrary_deep_directories(self):
with tempfile.TemporaryDirectory() as tmp:
mysql = Path(tmp) / "random" / "deep" / "folder" / "mysql.exe"
mysql.parent.mkdir(parents=True)
mysql.write_text("", encoding="utf-8")
def runner(command):
if command[0] == "mysql":
raise FileNotFoundError(command[0])
return subprocess.CompletedProcess(command, 0, "mysql 8.4\n", "")
results = check_environment(
runner=runner,
checks={"MySQL": ["mysql", "--version"]},
search_roots=[Path(tmp)],
)
self.assertFalse(results[0].installed)
def test_saves_and_loads_manual_paths(self):
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "tool-paths.json"
mysql = Path(tmp) / "mysql.exe"
save_tool_paths({"MySQL": mysql}, config)
loaded = load_tool_paths(config)
self.assertEqual(loaded, {"MySQL": mysql})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,163 @@
import unittest
import queue
from unittest.mock import patch
from deploy_tool.gui import DeployWorkbench, database_name_from_sql_files
import deploy_tool.gui as gui_module
class FakeWidget:
def __init__(self):
self.focused = False
self.seen = False
def focus_set(self):
self.focused = True
def see(self, index):
self.seen = index
class FakeButton:
def __init__(self):
self.state = None
def configure(self, **options):
self.state = options.get("state")
class GuiNavigationTests(unittest.TestCase):
def test_database_name_comes_from_first_sql_filename(self):
from pathlib import Path
self.assertEqual(database_name_from_sql_files([Path("db/movie.sql")]), "movie")
self.assertEqual(database_name_from_sql_files([]), "")
def test_deployment_running_state_updates_deploy_button(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.deployment_running = False
app.deploy_buttons = [FakeButton()]
set_running = getattr(DeployWorkbench, "_set_deployment_running", lambda self, running: None)
set_running(app, True)
self.assertTrue(app.deployment_running)
self.assertEqual([button.state for button in app.deploy_buttons], ["disabled"])
set_running(app, False)
self.assertFalse(app.deployment_running)
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
def test_running_deployment_ignores_another_deploy_request(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.deployment_running = True
plan_calls = []
logs = []
app._current_plan = lambda: plan_calls.append("planned") or []
app._append_log = logs.append
DeployWorkbench.run_deploy_plan_async(app)
self.assertEqual(plan_calls, [])
self.assertTrue(any("正在执行" in line for line in logs))
def test_confirmed_deployment_locks_buttons_before_worker_starts(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.deployment_running = False
app.deploy_buttons = [FakeButton()]
app._current_plan = lambda: [object()]
app.ui_queue = object()
app._thread_log = lambda value: None
with patch.object(gui_module.messagebox, "askyesno", return_value=True), patch.object(gui_module.threading, "Thread") as thread_class:
DeployWorkbench.run_deploy_plan_async(app)
self.assertTrue(app.deployment_running)
self.assertEqual([button.state for button in app.deploy_buttons], ["disabled"])
thread_class.return_value.start.assert_called_once_with()
def test_thread_start_failure_restores_deploy_buttons(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.deployment_running = False
app.deploy_buttons = [FakeButton()]
app._current_plan = lambda: [object()]
logs = []
app._append_log = logs.append
with patch.object(gui_module.messagebox, "askyesno", return_value=True), patch.object(gui_module.threading, "Thread") as thread_class:
thread_class.return_value.start.side_effect = RuntimeError("thread unavailable")
try:
DeployWorkbench.run_deploy_plan_async(app)
except RuntimeError:
pass
self.assertFalse(app.deployment_running)
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
self.assertTrue(any("thread unavailable" in line for line in logs))
def test_worker_exception_always_queues_deploy_done(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.ui_queue = queue.Queue()
run_worker = getattr(DeployWorkbench, "_run_deployment_worker", lambda self, plan: None)
with patch.object(gui_module, "run_steps", side_effect=RuntimeError("unexpected failure")):
run_worker(app, [object()])
events = []
while not app.ui_queue.empty():
events.append(app.ui_queue.get_nowait())
self.assertIn(("deploy_done", 1), events)
self.assertTrue(any(event == "log" and "unexpected failure" in str(payload) for event, payload in events))
def test_deploy_done_event_restores_deploy_buttons(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.deployment_running = True
app.deploy_buttons = [FakeButton()]
app.ui_queue = queue.Queue()
app.ui_queue.put(("deploy_done", 1))
logs = []
app._append_log = logs.append
app.after = lambda *args, **kwargs: None
DeployWorkbench._drain_queue(app)
self.assertFalse(app.deployment_running)
self.assertEqual([button.state for button in app.deploy_buttons], ["normal"])
self.assertTrue(any("退出码: 1" in line for line in logs))
def test_top_action_only_exposes_execution(self):
self.assertNotIn("plan", gui_module.TEXT)
self.assertEqual(gui_module.TEXT["deploy"], "执行部署")
def test_sidebar_navigation_focuses_logs(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.log_text = FakeWidget()
app.result_text = FakeWidget()
app.activate_sidebar_item("运行日志")
self.assertTrue(app.log_text.focused)
self.assertEqual(app.log_text.seen, "end")
def test_sidebar_navigation_focuses_database_config(self):
app = DeployWorkbench.__new__(DeployWorkbench)
app.database_widget = FakeWidget()
app.activate_sidebar_item("数据库")
self.assertTrue(app.database_widget.focused)
def test_sidebar_navigation_can_trigger_environment_check(self):
app = DeployWorkbench.__new__(DeployWorkbench)
calls = []
app.check_environment_async = lambda: calls.append("checked")
app.activate_sidebar_item("环境检测")
self.assertEqual(calls, ["checked"])
if __name__ == "__main__":
unittest.main()

100
tests/test_installer.py Normal file
View File

@@ -0,0 +1,100 @@
import tempfile
import unittest
from pathlib import Path
from deploy_tool.installer import build_install_command, explain_install_failure, format_install_output, recommend_jdk_version
from deploy_tool.scanner import scan_project
class InstallerTests(unittest.TestCase):
def test_recommends_jdk_8_from_maven_source_property(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
backend = root / "server"
(backend / "src" / "main" / "java").mkdir(parents=True)
(backend / "pom.xml").write_text(
"""
<project>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>spring-boot-starter-web</dependency>
</dependencies>
</project>
""",
encoding="utf-8",
)
scan = scan_project(root)
recommendation = recommend_jdk_version(scan)
self.assertEqual(recommendation.version, "8")
self.assertIn("maven.compiler.source", recommendation.reason)
def test_recommends_jdk_17_for_spring_boot_3(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
backend = root / "server"
(backend / "src" / "main" / "java").mkdir(parents=True)
(backend / "pom.xml").write_text(
"""
<project>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
</project>
""",
encoding="utf-8",
)
scan = scan_project(root)
recommendation = recommend_jdk_version(scan)
self.assertEqual(recommendation.version, "17")
self.assertIn("Spring Boot 3", recommendation.reason)
def test_defaults_to_jdk_8_without_project_signal(self):
recommendation = recommend_jdk_version(None)
self.assertEqual(recommendation.version, "8")
self.assertIn("默认", recommendation.reason)
def test_builds_jdk_winget_install_command(self):
command = build_install_command("JDK", version="8")
self.assertEqual(command, ["winget", "install", "-e", "--id", "EclipseAdoptium.Temurin.8.JDK"])
def test_builds_node_winget_install_command(self):
command = build_install_command("Node.js")
self.assertEqual(command, ["winget", "install", "-e", "--id", "OpenJS.NodeJS.LTS"])
def test_formats_winget_progress_without_garbled_bar(self):
raw = "████████████▒▒▒▒▒▒ 63%\r".encode("utf-8")
self.assertEqual(format_install_output(raw), ["安装进度: 63%"])
def test_keeps_readable_winget_text(self):
raw = "已找到 Eclipse Temurin JDK [EclipseAdoptium.Temurin.8.JDK]\n".encode("utf-8")
self.assertEqual(format_install_output(raw), ["已找到 Eclipse Temurin JDK [EclipseAdoptium.Temurin.8.JDK]"])
def test_explains_missing_maven_winget_package(self):
message = explain_install_failure(
"Maven",
2316632084,
["找不到与输入条件匹配的程序包。"],
)
self.assertIn("winget 当前源找不到 Maven 包", message)
self.assertIn("手动下载 Apache Maven", message)
self.assertIn("mvn.cmd", message)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,48 @@
import unittest
from pathlib import Path
from deploy_tool.path_manager import EnvironmentUpdate, build_environment_update, merge_path_entries, apply_user_environment
class FakeRegistry:
def __init__(self):
self.values = {}
self.broadcasted = False
def get_value(self, name):
return self.values.get(name, "")
def set_value(self, name, value):
self.values[name] = value
def broadcast_change(self):
self.broadcasted = True
class PathManagerTests(unittest.TestCase):
def test_merge_path_entries_appends_without_duplicates(self):
current = r"C:\Windows;C:\Java\bin"
merged = merge_path_entries(current, [Path(r"C:\Java\bin"), Path(r"C:\mysql\bin")])
self.assertEqual(merged, r"C:\Windows;C:\Java\bin;C:\mysql\bin")
def test_build_environment_update_sets_java_home_from_bin(self):
update = build_environment_update("JDK", Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin\java.exe"))
self.assertEqual(update.path_entries, [Path(r"C:\Program Files\Eclipse Adoptium\jdk-8\bin")])
self.assertEqual(update.env_vars["JAVA_HOME"], r"C:\Program Files\Eclipse Adoptium\jdk-8")
def test_apply_user_environment_writes_path_and_env_vars(self):
registry = FakeRegistry()
registry.values["Path"] = r"C:\Windows"
update = EnvironmentUpdate(path_entries=[Path(r"C:\Tools\bin")], env_vars={"JAVA_HOME": r"C:\Java"})
apply_user_environment(update, registry)
self.assertEqual(registry.values["Path"], r"C:\Windows;C:\Tools\bin")
self.assertEqual(registry.values["JAVA_HOME"], r"C:\Java")
self.assertTrue(registry.broadcasted)
if __name__ == "__main__":
unittest.main()

112
tests/test_scanner.py Normal file
View File

@@ -0,0 +1,112 @@
import json
import tempfile
import unittest
from pathlib import Path
from deploy_tool.scanner import scan_project
class ScannerTests(unittest.TestCase):
def test_prunes_generated_and_dependency_directories(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
frontend = root / "web"
frontend.mkdir()
(frontend / "package.json").write_text(
json.dumps(
{
"dependencies": {"vue": "^3.4.0"},
"scripts": {"dev": "vite", "build": "vite build"},
}
),
encoding="utf-8",
)
ignored_package = frontend / "node_modules" / "fake" / "package.json"
ignored_package.parent.mkdir(parents=True)
ignored_package.write_text(
json.dumps({"dependencies": {"vue": "^3.4.0"}, "scripts": {"dev": "vite"}}),
encoding="utf-8",
)
ignored_sql = frontend / "dist" / "bundled.sql"
ignored_sql.parent.mkdir()
ignored_sql.write_text("select 1;", encoding="utf-8")
result = scan_project(root)
self.assertEqual([item.path for item in result.detected_frontends], [frontend])
self.assertEqual(result.sql_files, [])
def test_detects_user_and_admin_frontends_with_ports_and_backend_context(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
backend = root / "server"
client = root / "前端" / "client_code"
admin = root / "前端" / "manage_code"
(backend / "src" / "main" / "java").mkdir(parents=True)
resources = backend / "src" / "main" / "resources"
resources.mkdir(parents=True)
(backend / "pom.xml").write_text("<project>spring-boot</project>", encoding="utf-8")
(resources / "application.yml").write_text(
"server:\n servlet:\n context-path: /movies\n", encoding="utf-8"
)
for frontend, port in ((client, 8082), (admin, 8081)):
frontend.mkdir(parents=True)
(frontend / "package.json").write_text(
json.dumps(
{
"dependencies": {"vue": "^3.4.0"},
"scripts": {"build": "vue-cli-service build", "serve": "vue-cli-service serve"},
}
),
encoding="utf-8",
)
(frontend / "vue.config.js").write_text(
f"module.exports = {{ devServer: {{ port: {port} }} }}", encoding="utf-8"
)
result = scan_project(root)
self.assertEqual([item.path for item in result.detected_frontends], [client, admin])
self.assertEqual([item.metadata["role"] for item in result.detected_frontends], ["user", "admin"])
self.assertEqual([item.metadata["port"] for item in result.detected_frontends], ["8082", "8081"])
self.assertEqual(result.backend.metadata["context_path"], "/movies")
def test_detects_spring_boot_vue_and_sql(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
backend = root / "server"
frontend = root / "web"
sql_dir = root / "db"
(backend / "src" / "main" / "java").mkdir(parents=True)
(backend / "pom.xml").write_text(
"<project><dependencies><dependency>spring-boot-starter-web</dependency></dependencies></project>",
encoding="utf-8",
)
frontend.mkdir()
(frontend / "package.json").write_text(
json.dumps({"dependencies": {"vue": "^3.4.0"}, "scripts": {"build": "vite build"}}),
encoding="utf-8",
)
sql_dir.mkdir()
(sql_dir / "schema.sql").write_text("create table demo(id int);", encoding="utf-8")
result = scan_project(root)
self.assertEqual(result.backend.path, backend)
self.assertEqual(result.frontend.path, frontend)
self.assertEqual(result.sql_files, [sql_dir / "schema.sql"])
def test_empty_folder_reports_missing_parts(self):
with tempfile.TemporaryDirectory() as tmp:
result = scan_project(Path(tmp))
self.assertIsNone(result.backend)
self.assertIsNone(result.frontend)
self.assertEqual(result.sql_files, [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from deploy_tool.service_console import main
class ServiceConsoleTests(unittest.TestCase):
def test_tees_service_output_to_log_and_removes_pid_file(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
log_path = root / "service.log"
pid_path = root / "service.pid"
argv = [
"service_console.py",
"--log",
str(log_path),
"--pid-file",
str(pid_path),
"--",
sys.executable,
"-c",
"print('service-ready')",
]
with patch.object(sys, "argv", argv):
exit_code = main()
self.assertEqual(exit_code, 0)
self.assertIn("service-ready", log_path.read_text(encoding="utf-8"))
self.assertFalse(pid_path.exists())
if __name__ == "__main__":
unittest.main()