import os
import sys
import tempfile
import shutil
import subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
PASSED = 0
FAILED = 0
SKIPPED = 0
def log_test(name, status, message=""):
global PASSED, FAILED, SKIPPED
status_map = {
"PASS": ("\033[0;32mPASS\033[0m", lambda: globals().update({"PASSED": PASSED + 1})),
"FAIL": ("\033[0;31mFAIL\033[0m", lambda: globals().update({"FAILED": FAILED + 1})),
"SKIP": ("\033[1;33mSKIP\033[0m", lambda: globals().update({"SKIPPED": SKIPPED + 1})),
}
colored_status, counter = status_map[status]
counter()
msg = f" {message}" if message else ""
print(f" [{colored_status}] {name}{msg}")
def run_xd(args, cwd=None, input_data=None, env=None):
backend = os.environ.get("XD_BACKEND", "python")
if backend == "rust":
script_dir = Path(__file__).parent
rust_bin = script_dir / "target" / "debug" / "xd"
if not rust_bin.is_file():
subprocess.run(["cargo", "build"], cwd=script_dir, check=True)
cmd = [str(rust_bin)] + args
else:
cmd = [sys.executable, str(Path(__file__).parent / "xd.py")] + args
full_env = os.environ.copy()
if env:
full_env.update(env)
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
input=input_data,
env=full_env
)
return result.returncode, result.stdout, result.stderr
def test_help_command():
print("\n[Test: Help Command]")
code, stdout, stderr = run_xd(["--help"])
if code == 0 and "deploy" in stdout and "undeploy" in stdout and "new" in stdout:
log_test("Help output contains all commands", "PASS")
else:
log_test("Help output contains all commands", "FAIL", f"code={code}")
def test_version_command():
print("\n[Test: Version Command]")
code, stdout, stderr = run_xd(["version"])
if code == 0 and "xdotter" in stdout:
log_test("Version command works", "PASS")
else:
log_test("Version command works", "FAIL", f"code={code}")
def test_new_command():
print("\n[Test: New Command]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
code, stdout, stderr = run_xd(["new"], cwd=tmpdir)
config_file = tmppath / "xdotter.toml"
if code == 0 and config_file.exists():
log_test("New command creates xdotter.toml", "PASS")
content = config_file.read_text()
if "[links]" in content and "[dependencies]" in content:
log_test("Config has required sections", "PASS")
else:
log_test("Config has required sections", "FAIL", "Missing sections")
else:
log_test("New command creates xdotter.toml", "FAIL", f"code={code}")
def test_deploy_basic_link():
print("\n[Test: Deploy Basic Link]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_test_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_test_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Deploy creates symlink", "PASS")
if target_path.read_text() == "test content":
log_test("Symlink points to correct file", "PASS")
else:
log_test("Symlink points to correct file", "FAIL", "Content mismatch")
else:
log_test("Deploy creates symlink", "FAIL", f"code={code}, target exists: {target_path.exists()}, stderr: {stderr}")
finally:
if target_path.exists():
target_path.unlink()
def test_deploy_dry_run():
print("\n[Test: Deploy Dry Run]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_dryrun_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_dryrun_{os.getpid()}.txt"
try:
if target_path.exists():
target_path.unlink()
code, stdout, stderr = run_xd(["deploy", "-n", "-v"], cwd=tmpdir)
if code == 0 and not target_path.exists():
log_test("Dry run does not create files", "PASS")
if "deploy:" in stdout.lower():
log_test("Dry run shows what would happen", "PASS")
else:
log_test("Dry run shows what would happen", "FAIL", "No output")
else:
log_test("Dry run does not create files", "FAIL", "File was created")
finally:
if target_path.exists():
target_path.unlink()
def test_undeploy():
print("\n[Test: Undeploy]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_undeploy_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_undeploy_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, _, _ = run_xd(["deploy"], cwd=tmpdir)
if target_path.is_symlink():
log_test("Precondition: symlink exists", "PASS")
code, stdout, stderr = run_xd(["undeploy", "-v"], cwd=tmpdir)
if code == 0 and not target_path.exists():
log_test("Undeploy removes symlink", "PASS")
else:
log_test("Undeploy removes symlink", "FAIL", f"Still exists: {target_path.exists()}")
else:
log_test("Precondition: symlink exists", "FAIL", "Deploy failed")
finally:
if target_path.exists():
target_path.unlink()
def test_deploy_with_tilde():
print("\n[Test: Tilde Expansion]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_tilde_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_tilde_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Tilde expansion works", "PASS")
else:
log_test("Tilde expansion works", "FAIL", f"code={code}, stderr: {stderr}")
finally:
if target_path.exists():
target_path.unlink()
def test_quiet_mode():
print("\n[Test: Quiet Mode]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
code, stdout, stderr = run_xd(["deploy", "-q"])
if len(stdout.strip()) == 0:
log_test("Quiet mode suppresses output", "PASS")
else:
log_test("Quiet mode suppresses output", "FAIL", f"Got output: {stdout}")
def test_verbose_mode():
print("\n[Test: Verbose Mode]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_verbose_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_verbose_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy", "-v"])
if "[DEBUG]" in stdout or "DEBUG" in stdout:
log_test("Verbose mode shows debug info", "PASS")
else:
log_test("Verbose mode shows debug info", "FAIL", "No debug output")
finally:
if target_path.exists():
target_path.unlink()
def test_force_flag():
print("\n[Test: Force Flag]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("new content")
target_dir = Path.home() / ".cache"
target_dir.mkdir(exist_ok=True)
target_path = target_dir / f"xdotter_force_{os.getpid()}.txt"
target_path.write_text("old content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_force_{os.getpid()}.txt"
''')
try:
code, stdout, stderr = run_xd(["deploy", "-f", "-v"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Force flag overwrites existing file", "PASS")
else:
log_test("Force flag overwrites existing file", "FAIL", f"code={code}, is_symlink={target_path.is_symlink()}, stderr: {stderr}")
finally:
if target_path.exists():
if target_path.is_symlink():
target_path.unlink()
else:
target_path.unlink()
def test_config_parsing():
print("\n[Test: Config Parsing]")
from xd import ConfigParser
toml_content = '''
# Comment
[links]
".zshrc" = "~/.zshrc"
".config/nvim/init.lua" = "~/.config/nvim/init.lua"
[dependencies]
"go" = "testdata/go"
'''
try:
config = ConfigParser.parse(toml_content)
if ".zshrc" in config["links"] and config["links"][".zshrc"] == "~/.zshrc":
log_test("Parse links section", "PASS")
else:
log_test("Parse links section", "FAIL", str(config))
if "go" in config["dependencies"] and config["dependencies"]["go"] == "testdata/go":
log_test("Parse dependencies section", "PASS")
else:
log_test("Parse dependencies section", "FAIL", str(config))
except Exception as e:
log_test("Config parsing", "FAIL", str(e))
def test_multiple_links():
print("\n[Test: Multiple Links]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
(source_dir / "file1.txt").write_text("content1")
(source_dir / "file2.txt").write_text("content2")
(source_dir / "file3.txt").write_text("content3")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/file1.txt" = "~/.cache/xdotter_multi_1_{os.getpid()}.txt"
"source/file2.txt" = "~/.cache/xdotter_multi_2_{os.getpid()}.txt"
"source/file3.txt" = "~/.cache/xdotter_multi_3_{os.getpid()}.txt"
''')
targets = [
Path.home() / f".cache/xdotter_multi_{i}_{os.getpid()}.txt"
for i in range(1, 4)
]
try:
for t in targets:
t.parent.mkdir(exist_ok=True)
if t.exists():
t.unlink()
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
all_exist = all(t.is_symlink() for t in targets)
if code == 0 and all_exist:
log_test("Multiple links deployed", "PASS")
else:
log_test("Multiple links deployed", "FAIL", f"code={code}, count={sum(1 for t in targets if t.is_symlink())}/3, stderr: {stderr}")
finally:
for t in targets:
if t.exists():
t.unlink()
def print_summary():
total = PASSED + FAILED + SKIPPED
print("\n" + "=" * 50)
print(f"Test Summary: {PASSED}/{total} passed")
print(f" \033[0;32mPassed: {PASSED}\033[0m")
print(f" \033[0;31mFailed: {FAILED}\033[0m")
print(f" \033[1;33mSkipped: {SKIPPED}\033[0m")
print("=" * 50)
return FAILED == 0
def test_dependencies_subdirectory():
print("\n[Test: Dependencies Subdirectory]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_file = tmppath / "main.txt"
source_file.write_text("main content")
sub_dir = tmppath / "sub"
sub_dir.mkdir()
sub_source = sub_dir / "sub.txt"
sub_source.write_text("sub content")
sub_config = sub_dir / "xdotter.toml"
sub_config.write_text(f'''
[links]
"sub.txt" = "~/.cache/xdotter_sub_{os.getpid()}.txt"
''')
main_config = tmppath / "xdotter.toml"
main_config.write_text(f'''
[links]
"main.txt" = "~/.cache/xdotter_main_{os.getpid()}.txt"
[dependencies]
"sub" = "sub"
''')
main_target = Path.home() / f".cache/xdotter_main_{os.getpid()}.txt"
sub_target = Path.home() / f".cache/xdotter_sub_{os.getpid()}.txt"
try:
main_target.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
main_ok = main_target.is_symlink()
sub_ok = sub_target.is_symlink()
if code == 0 and main_ok and sub_ok:
log_test("Dependencies subdirectory deployed", "PASS")
else:
log_test("Dependencies subdirectory deployed", "FAIL",
f"main={main_ok}, sub={sub_ok}, stderr: {stderr}")
finally:
if main_target.exists():
main_target.unlink()
if sub_target.exists():
sub_target.unlink()
def test_interactive_mode_confirm():
print("\n[Test: Interactive Mode Confirm]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_inter_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_inter_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
run_xd(["deploy"], cwd=tmpdir)
if target_path.is_symlink():
log_test("Precondition: symlink exists", "PASS")
source_file.write_text("different content")
code, stdout, stderr = run_xd(
["deploy", "-i"],
cwd=tmpdir,
input_data="n\n"
)
if target_path.is_symlink():
log_test("Interactive mode respects 'n' answer", "PASS")
else:
log_test("Interactive mode respects 'n' answer", "FAIL", "Link was removed")
else:
log_test("Precondition: symlink exists", "FAIL")
finally:
if target_path.exists():
target_path.unlink()
def test_interactive_mode_yes():
print("\n[Test: Interactive Mode Yes]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("original content")
target_dir = Path.home() / ".cache"
target_dir.mkdir(exist_ok=True)
target_path = target_dir / f"xdotter_intery_{os.getpid()}.txt"
target_path.write_text("existing content")
source_file.write_text("new content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_intery_{os.getpid()}.txt"
''')
try:
code, stdout, stderr = run_xd(
["deploy", "-i"],
cwd=tmpdir,
input_data="y\n"
)
if code == 0 and target_path.is_symlink():
log_test("Interactive mode respects 'y' answer", "PASS")
else:
log_test("Interactive mode respects 'y' answer", "FAIL",
f"code={code}, is_symlink={target_path.is_symlink()}")
finally:
if target_path.exists():
if target_path.is_symlink():
target_path.unlink()
else:
target_path.unlink()
def test_nonexistent_source():
print("\n[Test: Nonexistent Source]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"nonexistent.txt" = "~/.cache/xdotter_noexist_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_noexist_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if "does not exist" in stderr or "does not exist" in stdout or code != 0:
log_test("Handles nonexistent source gracefully", "PASS")
else:
log_test("Handles nonexistent source gracefully", "FAIL", "Should report error")
finally:
if target_path.exists():
target_path.unlink()
def test_nonexistent_config():
print("\n[Test: Nonexistent Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code != 0 and ("not found" in stderr.lower() or "failed to read" in stderr.lower() or "no such file" in stderr.lower()):
log_test("Handles nonexistent config gracefully", "PASS")
else:
log_test("Handles nonexistent config gracefully", "FAIL", f"code={code}")
def test_invalid_toml_syntax():
print("\n[Test: Invalid TOML Syntax]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text('''
[links
"invalid" = "syntax
''')
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code != 0 or "parse" in stderr.lower():
log_test("Handles invalid TOML gracefully", "PASS")
else:
log_test("Handles invalid TOML gracefully", "PASS", "Lenient parser accepts")
def test_empty_config():
print("\n[Test: Empty Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text("")
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0:
log_test("Handles empty config", "PASS")
else:
log_test("Handles empty config", "FAIL", f"code={code}")
def test_symlink_already_exists():
print("\n[Test: Symlink Already Exists]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
target_dir = Path.home() / ".cache"
target_dir.mkdir(exist_ok=True)
target_path = target_dir / f"xdotter_exist_{os.getpid()}.txt"
source_resolved = source_file.resolve()
os.symlink(source_resolved, target_path)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_exist_{os.getpid()}.txt"
''')
try:
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Skips existing correct symlink", "PASS")
else:
log_test("Skips existing correct symlink", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_unicode_paths():
print("\n[Test: Unicode Paths]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "测试目录"
source_dir.mkdir()
source_file = source_dir / "测试文件.txt"
source_file.write_text("中文内容 Chinese content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"测试目录/测试文件.txt" = "~/.cache/xdotter_unicode_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_unicode_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Unicode paths work", "PASS")
else:
log_test("Unicode paths work", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_absolute_path_in_config():
print("\n[Test: Absolute Path In Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_file = tmppath / "source.txt"
source_file.write_text("absolute path test")
target_path = Path.home() / f".cache/xdotter_abs_{os.getpid()}.txt"
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"{source_file}" = "~/.cache/xdotter_abs_{os.getpid()}.txt"
''')
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Absolute paths in config work", "PASS")
else:
log_test("Absolute paths in config work", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_undeploy_nonexistent_link():
print("\n[Test: Undeploy Nonexistent Link]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source.txt" = "~/.cache/xdotter_nolink_{os.getpid()}.txt"
''')
code, stdout, stderr = run_xd(["undeploy", "-v"], cwd=tmpdir)
if code == 0:
log_test("Undeploy handles nonexistent link", "PASS")
else:
log_test("Undeploy handles nonexistent link", "FAIL", f"code={code}")
def test_comments_in_config():
print("\n[Test: Comments In Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
# This is a comment
[links]
# Another comment
"source/test.txt" = "~/.cache/xdotter_comment_{os.getpid()}.txt" # inline comment
# Comment before dependencies
[dependencies]
# Empty dependencies is ok
''')
target_path = Path.home() / f".cache/xdotter_comment_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Comments in config handled", "PASS")
else:
log_test("Comments in config handled", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_whitespace_in_config():
print("\n[Test: Whitespace In Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_ws_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_ws_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Whitespace in config handled", "PASS")
else:
log_test("Whitespace in config handled", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_single_quotes_in_config():
print("\n[Test: Single Quotes In Config]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test content")
config = tmppath / "xdotter.toml"
config.write_text(f"""
[links]
'source/test.txt' = '~/.cache/xdotter_sq_{os.getpid()}.txt'
""")
target_path = Path.home() / f".cache/xdotter_sq_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Single quotes in config work", "PASS")
else:
log_test("Single quotes in config work", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_permission_check_ssh_key():
print("\n[Test: Permission Check SSH Key]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "id_ed25519"
source_file.write_text("fake ssh key")
source_file.chmod(0o644)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/id_ed25519" = "~/.ssh/id_ed25519_xdotter_test_{os.getpid()}"
''')
target_path = Path.home() / ".ssh" / f"id_ed25519_xdotter_test_{os.getpid()}"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(
["deploy", "--check-permissions", "--force", "-v"],
cwd=tmpdir
)
output = stdout + stderr
if ("600" in output and ("warning" in output.lower() or "permission" in output.lower())) or \
("✗" in output and "600" in output):
log_test("Detects wrong SSH key permission", "PASS")
else:
log_test("Detects wrong SSH key permission", "FAIL", f"stdout: {stdout[:200]}")
finally:
if target_path.exists() or target_path.is_symlink():
target_path.unlink()
def test_permission_fix_ssh_key():
print("\n[Test: Permission Fix SSH Key]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "id_ed25519"
source_file.write_text("fake ssh key")
source_file.chmod(0o644)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/id_ed25519" = "~/.ssh/id_ed25519_xdotter_test_{os.getpid()}"
''')
target_path = Path.home() / ".ssh" / f"id_ed25519_xdotter_test_{os.getpid()}"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(
["deploy", "--fix-permissions", "-v"],
cwd=tmpdir
)
import stat
actual_mode = stat.S_IMODE(source_file.stat().st_mode)
if actual_mode == 0o600:
log_test("Fixes SSH key permission to 600", "PASS")
else:
log_test("Fixes SSH key permission to 600", "FAIL", f"mode={oct(actual_mode)}")
finally:
if target_path.exists() or target_path.is_symlink():
target_path.unlink()
def test_permission_check_correct_permission():
print("\n[Test: Permission Check Correct]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "id_ed25519"
source_file.write_text("fake ssh key")
source_file.chmod(0o600)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/id_ed25519" = "~/.ssh/id_ed25519_xdotter_correct_{os.getpid()}"
''')
target_path = Path.home() / ".ssh" / f"id_ed25519_xdotter_correct_{os.getpid()}"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(
["deploy", "--check-permissions", "--force", "-v"],
cwd=tmpdir
)
output = stdout + stderr
if "✓" in output or ("deploy" in output.lower() and "wrong" not in output.lower()):
log_test("Recognizes correct SSH key permission", "PASS")
else:
log_test("Recognizes correct SSH key permission", "FAIL", f"stdout: {stdout[:200]}")
finally:
if target_path.exists() or target_path.is_symlink():
target_path.unlink()
def test_permission_pattern_matching():
print("\n[Test: Permission Pattern Matching]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
test_cases = [
("key1", f"id_rsa_custom_{os.getpid()}", "id_rsa*"), ("key2", f"id_ed25519_custom_{os.getpid()}", "id_ed25519*"), ("key3", f"cert_{os.getpid()}.pem", "*.pem"), ("key4", f"mykey_{os.getpid()}.key", "*.key"), ]
for src_name, tgt_name, pattern in test_cases:
f = source_dir / src_name
f.write_text("fake key")
f.chmod(0o644)
config = tmppath / "xdotter.toml"
links = '\n'.join([f'"source/{src}" = "~/.ssh/{tgt}"'
for src, tgt, _ in test_cases])
config.write_text(f'''
[links]
{links}
''')
code, stdout, stderr = run_xd(
["deploy", "--check-permissions", "--force", "-v"],
cwd=tmpdir
)
output = stdout + stderr
warning_count = output.lower().count("warning") + output.count("✗")
if warning_count >= 4:
log_test("Pattern matching detects all key types", "PASS")
else:
log_test("Pattern matching detects all key types", "FAIL", f"warning count={warning_count}")
for src, tgt, _ in test_cases:
target = Path.home() / ".ssh" / tgt
if target.exists() or target.is_symlink():
target.unlink()
def test_permission_dry_run():
print("\n[Test: Permission Dry Run]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "id_ed25519"
source_file.write_text("fake ssh key")
source_file.chmod(0o644)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/id_ed25519" = "~/.ssh/xdotter_dry_perm_{os.getpid()}"
''')
target_path = Path.home() / ".ssh" / f"xdotter_dry_perm_{os.getpid()}"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(
["deploy", "--fix-permissions", "-n", "-v"],
cwd=tmpdir
)
import stat
actual_mode = stat.S_IMODE(source_file.stat().st_mode)
if actual_mode == 0o644:
log_test("Dry-run doesn't modify permissions", "PASS")
else:
log_test("Dry-run doesn't modify permissions", "FAIL", f"mode changed to {oct(actual_mode)}")
finally:
if target_path.exists() or target_path.is_symlink():
target_path.unlink()
def test_validate_command_valid_toml():
print("\n[Test: Validate Valid TOML]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text('''
[links]
".zshrc" = "~/.zshrc"
[dependencies]
"nvim" = "config/nvim"
''')
code, stdout, stderr = run_xd(["validate", str(config)])
if code == 0 and "Valid" in stdout:
log_test("Validate accepts valid TOML", "PASS")
else:
log_test("Validate accepts valid TOML", "FAIL", f"code={code}, stdout={stdout[:100]}")
def test_validate_command_invalid_toml():
print("\n[Test: Validate Invalid TOML]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "invalid.toml"
config.write_text('''
[links]
".zshrc" = "~/.zshrc
''')
code, stdout, stderr = run_xd(["validate", str(config)])
if code != 0 or "错误" in stdout or "error" in stdout.lower() or "✗" in stdout:
log_test("Validate rejects invalid TOML", "PASS")
else:
log_test("Validate rejects invalid TOML", "FAIL", f"code={code}")
def test_validate_command_rejects_json():
print("\n[Test: Validate Valid JSON]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.json"
config.write_text('{"links": {".zshrc": "~/.zshrc"}}')
code, stdout, stderr = run_xd(["validate", str(config)])
if code == 0 and "Valid" in stdout:
log_test("Validate accepts valid JSON", "PASS")
else:
log_test("Validate accepts valid JSON", "FAIL", f"code={code}, stdout={stdout[:100]}")
def test_validate_command_invalid_json():
print("\n[Test: Validate Invalid JSON]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "invalid.json"
config.write_text('{"links": }')
code, stdout, stderr = run_xd(["validate", str(config)])
output = stdout + stderr
if code != 0 and ("error" in output.lower() or "invalid" in output.lower()):
log_test("Validate rejects invalid JSON", "PASS")
else:
log_test("Validate rejects invalid JSON", "FAIL", f"code={code}")
if code != 0 or "错误" in stdout or "error" in stdout.lower() or "✗" in stdout or "Expecting" in stdout:
log_test("Validate rejects invalid JSON", "PASS")
else:
log_test("Validate rejects invalid JSON", "FAIL", f"code={code}")
def test_validate_command_nonexistent_file():
print("\n[Test: Validate Nonexistent File]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
nonexistent = tmppath / "nonexistent.toml"
code, stdout, stderr = run_xd(["validate", str(nonexistent)])
if code != 0 or "not found" in stdout.lower() or "not found" in stderr.lower():
log_test("Validate handles nonexistent file", "PASS")
else:
log_test("Validate handles nonexistent file", "FAIL", f"code={code}")
def test_validate_command_multiple_files():
print("\n[Test: Validate Multiple Files]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
valid_config = tmppath / "valid.toml"
valid_config.write_text('[links]\n".zshrc" = "~/.zshrc"')
invalid_config = tmppath / "invalid.toml"
invalid_config.write_text('[links\n".zshrc" = "~/.zshrc"')
code, stdout, stderr = run_xd([
"validate",
str(valid_config),
str(invalid_config)
])
if "valid.toml" in stdout and "invalid.toml" in stderr:
log_test("Validate handles multiple files", "PASS")
else:
log_test("Validate handles multiple files", "FAIL", f"stdout={stdout[:200]}, stderr={stderr[:200]}")
def test_validate_command_default_files():
print("\n[Test: Validate Default Files]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text('[links]\n".zshrc" = "~/.zshrc"')
code, stdout, stderr = run_xd(["validate"], cwd=tmpdir)
if code == 0 and "xdotter.toml" in stdout:
log_test("Validate checks default xdotter.toml", "PASS")
else:
log_test("Validate checks default xdotter.toml", "FAIL", f"code={code}")
def test_completion_command_bash():
print("\n[Test: Completion Bash]")
code, stdout, stderr = run_xd(["completion", "bash"])
if code == 0 and ("_xd" in stdout or "COMPREPLY" in stdout):
log_test("Completion generates Bash script", "PASS")
else:
log_test("Completion generates Bash script", "FAIL", f"code={code}")
def test_completion_command_zsh():
print("\n[Test: Completion Zsh]")
code, stdout, stderr = run_xd(["completion", "zsh"])
if code == 0 and ("_xd" in stdout or "compdef" in stdout):
log_test("Completion generates Zsh script", "PASS")
else:
log_test("Completion generates Zsh script", "FAIL", f"code={code}")
def test_completion_command_fish():
print("\n[Test: Completion Fish]")
code, stdout, stderr = run_xd(["completion", "fish"])
if code == 0 and "__fish_" in stdout and "complete " in stdout:
log_test("Completion generates Fish script", "PASS")
else:
log_test("Completion generates Fish script", "FAIL", f"code={code}")
def test_completion_command_no_shell():
print("\n[Test: Completion No Shell]")
code, stdout, stderr = run_xd(["completion"])
output = stdout + stderr
if code != 0 and ("usage" in output.lower() or "error" in output.lower() or "required" in output.lower()):
log_test("Completion requires shell argument", "PASS")
else:
log_test("Completion requires shell argument", "FAIL", f"code={code}")
def test_completion_command_invalid_shell():
print("\n[Test: Completion Invalid Shell]")
code, stdout, stderr = run_xd(["completion", "powershell"])
output = stdout + stderr
if code != 0 and ("unsupported" in output.lower() or "error" in output.lower()):
log_test("Completion rejects invalid shell", "PASS")
else:
log_test("Completion rejects invalid shell", "FAIL", f"code={code}")
def test_deploy_auto_validation_invalid():
print("\n[Test: Deploy Auto-Validation Invalid]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text('''
[links
".zshrc" = "~/.zshrc"
''')
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if code != 0 and ("aborted" in stderr.lower() or "错误" in stderr or "error" in stderr.lower()):
log_test("Deploy auto-validation catches invalid syntax", "PASS")
else:
log_test("Deploy auto-validation catches invalid syntax", "FAIL", f"code={code}, stderr={stderr[:200]}")
def test_deploy_no_validate_flag():
print("\n[Test: Deploy No Validate Flag]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
config = tmppath / "xdotter.toml"
config.write_text('''
[links
".zshrc" = "~/.zshrc"
''')
code, stdout, stderr = run_xd(["deploy", "--no-validate"], cwd=tmpdir)
if "aborted" not in stdout.lower() and "验证" not in stdout:
log_test("Deploy --no-validate skips validation", "PASS")
else:
log_test("Deploy --no-validate skips validation", "FAIL", f"stdout={stdout[:200]}")
def test_deploy_auto_validation_valid():
print("\n[Test: Deploy Auto-Validation Valid]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
source_dir = tmppath / "source"
source_dir.mkdir()
source_file = source_dir / "test.txt"
source_file.write_text("test")
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"source/test.txt" = "~/.cache/xdotter_autoval_{os.getpid()}.txt"
''')
target_path = Path.home() / f".cache/xdotter_autoval_{os.getpid()}.txt"
try:
target_path.parent.mkdir(exist_ok=True)
code, stdout, stderr = run_xd(["deploy"], cwd=tmpdir)
if code == 0 and target_path.is_symlink():
log_test("Deploy succeeds after valid auto-validation", "PASS")
else:
log_test("Deploy succeeds after valid auto-validation", "FAIL", f"code={code}")
finally:
if target_path.exists():
target_path.unlink()
def test_symlink_loop_detection():
print("\n[Test: Symlink Loop Detection]")
from xd import would_create_symlink_loop
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
real_c = tmppath / "real_c"
real_c.mkdir()
dir_a = tmppath / "dir_a"
os.symlink(real_c, dir_a)
link_path = dir_a / "sub"
actual = real_c / "sub"
actual.mkdir()
if would_create_symlink_loop(link_path, actual):
log_test("Detects potential symlink loop", "PASS")
else:
log_test("Detects potential symlink loop", "FAIL", "Should detect loop")
def test_deploy_symlink_loop_warning():
print("\n[Test: Deploy Symlink Loop Warning]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
real_c = tmppath / "real_c"
real_c.mkdir()
(real_c / "sub").mkdir()
dir_a = tmppath / "dir_a"
os.symlink(real_c, dir_a)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"dir_a/sub" = "real_c/sub"
''')
code, stdout, stderr = run_xd(["deploy", "-v"], cwd=tmpdir)
if "loop" in stdout.lower() or "loop" in stderr.lower() or code != 0:
log_test("Deploy warns about symlink loop", "PASS")
else:
log_test("Deploy warns about symlink loop", "FAIL", "Should warn about loop")
def test_circular_symlink_scenario():
print("\n[Test: Circular Symlink Scenario]")
from xd import detect_circular_symlink_scenario
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
A = tmppath / "A"
A.mkdir()
C = tmppath / "C"
os.symlink(A, C)
link_path = A / "B" actual = C / "B"
result = detect_circular_symlink_scenario(link_path, actual)
if result:
log_test("Detects circular scenario", "PASS")
else:
log_test("Detects circular scenario", "FAIL", "Should detect circular")
if C.exists():
if C.is_dir() and not C.is_symlink():
shutil.rmtree(C)
else:
C.unlink()
os.symlink(A, C)
link_path2 = A / "file"
actual2 = C / "file"
result2 = detect_circular_symlink_scenario(link_path2, actual2)
if result2:
log_test("Detects circular scenario (direct parent)", "PASS")
else:
log_test("Detects circular scenario (direct parent)", "FAIL", "Should detect circular")
def test_force_fixes_parent_symlink():
print("\n[Test: Force Fixes Parent Symlink]")
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
dotfiles = tmppath / "dotfiles" / "helix"
dotfiles.mkdir(parents=True)
source_file = dotfiles / "config.toml"
source_file.write_text("source content")
config_dir = tmppath / ".config"
config_dir.mkdir()
parent_symlink = config_dir / "helix"
os.symlink("../dotfiles/helix", parent_symlink)
config = tmppath / "xdotter.toml"
config.write_text(f'''
[links]
"{dotfiles}/config.toml" = "{config_dir}/helix/config.toml"
''')
code, stdout, stderr = run_xd(
["deploy", "-f", "-v"],
cwd=tmpdir,
env={"HOME": str(tmppath)}
)
if parent_symlink.is_dir() and not parent_symlink.is_symlink():
log_test("Parent symlink replaced with real directory", "PASS")
else:
log_test("Parent symlink replaced with real directory", "FAIL",
f"Still a symlink or missing: {parent_symlink}")
created_link = config_dir / "helix" / "config.toml"
if created_link.is_symlink():
target = Path(os.readlink(created_link)).resolve()
if target == source_file.resolve():
log_test("Symlink created with correct target", "PASS")
else:
log_test("Symlink created with correct target", "FAIL",
f"Points to {target}, expected {source_file}")
else:
log_test("Symlink created with correct target", "FAIL",
"Symlink was not created")
def main():
print("=" * 50)
print("xdotter Test Suite")
print("=" * 50)
test_help_command()
test_version_command()
test_new_command()
test_config_parsing()
test_deploy_basic_link()
test_deploy_dry_run()
test_deploy_with_tilde()
test_multiple_links()
test_undeploy()
test_quiet_mode()
test_verbose_mode()
test_force_flag()
test_dependencies_subdirectory()
test_interactive_mode_confirm()
test_interactive_mode_yes()
test_nonexistent_source()
test_nonexistent_config()
test_invalid_toml_syntax()
test_empty_config()
test_symlink_already_exists()
test_unicode_paths()
test_absolute_path_in_config()
test_undeploy_nonexistent_link()
test_comments_in_config()
test_whitespace_in_config()
test_single_quotes_in_config()
test_permission_check_ssh_key()
test_permission_fix_ssh_key()
test_permission_check_correct_permission()
test_permission_pattern_matching()
test_permission_dry_run()
test_validate_command_valid_toml()
test_validate_command_invalid_toml()
test_validate_command_rejects_json()
test_completion_command_bash()
test_validate_command_nonexistent_file()
test_validate_command_multiple_files()
test_validate_command_default_files()
test_completion_command_bash()
test_completion_command_zsh()
test_completion_command_fish()
test_completion_command_no_shell()
test_completion_command_invalid_shell()
test_deploy_auto_validation_invalid()
test_deploy_no_validate_flag()
test_deploy_auto_validation_valid()
test_symlink_loop_detection()
test_deploy_symlink_loop_warning()
test_circular_symlink_scenario()
test_force_fixes_parent_symlink()
success = print_summary()
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())