Files
dotfiles/tests/test_install.sh
T
ddadminandClaude Opus 5.5 d65403efb5 feat(agents): прокси для Codex и Claude задаётся в их настройках
- Зачем: прокси из окружения запуска до API не доходил. Запросы Codex
  отправляет общий демон app-server, и маршрут зависел от того, кто запустил
  демон первым. Нужно было и обратное: Codex и Claude должны работать без
  прокси, даже если запускающее приложение его задало.
- Что: шаблоны codex/proxy.env (копируется в ~/.codex/.env) и
  claude/proxy-settings.json (блок env добавляется в ~/.claude/settings.json).
  По умолчанию прокси 127.0.0.1:12334. install.sh не трогает выбор, уже
  сделанный на машине. В README описано, как переключить машину на работу
  без прокси.
- Проверка: tests/test_install.sh и tests/test_proxy_env_sync.py проходят.
  Вручную: значения из ~/.codex/.env и из env в настройках Claude перекрывают
  унаследованные переменные; codex doctor проходит через Xray; очищенные
  адреса вместе с NO_PROXY=* дают прямое подключение.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:00:55 +02:00

773 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Один корневой временный каталог на прогон: тестовые деревья копируют заметную
# часть репозитория и без уборки переполняют /tmp за несколько прогонов.
TEST_TMP_ROOT="$(mktemp -d /tmp/dotfiles-install-test.XXXXXX)"
trap 'rm -rf "$TEST_TMP_ROOT"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_file_contains() {
local file="$1"
local pattern="$2"
grep -Fq "$pattern" "$file" || fail "Expected '$pattern' in $file"
}
assert_not_exists() {
local path="$1"
# -e не видит битые симлинки, поэтому проверяем и -L
[ ! -e "$path" ] && [ ! -L "$path" ] || fail "Path should not exist: $path"
}
setup_fixture() {
local fixture_root repo_root bin_dir
fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")"
repo_root="$fixture_root/repo"
bin_dir="$fixture_root/bin"
mkdir -p "$repo_root" "$bin_dir"
# Список файлов соответствует свежему клону: только отслеживаемые Git-файлы
# (новые файлы видны после git add). Содержимое берётся из текущего рабочего
# дерева. Локальные данные инструментов внутри пакетов (vscode/.vscode-server,
# zed/.config/opencode и т.п.) весят гигабайты, переполняют /tmp и ломают
# stow конфликтами, поэтому в тестовое дерево не попадает.
git -C "$REPO_ROOT" ls-files -z --cached --others --exclude-standard -- \
install.sh bash tmux vim zed kitty wezterm vscode agents opencode gemini \
claude codex proxy-client chatgpt scripts \
| tar -C "$REPO_ROOT" --null -T - -cf - \
| tar -xf - -C "$repo_root"
cat > "$bin_dir/fake-git" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ "${1:-}" = "-C" ]; then
shift 2
fi
case "${1:-}" in
rev-parse)
exit 0
;;
show)
printf '%s' "${FAKE_GIT_SHOW_CONTENT:-}"
;;
*)
echo "unexpected git invocation: $*" >&2
exit 1
;;
esac
EOF
cat > "$bin_dir/fake-sudo" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec "$@"
EOF
chmod +x "$bin_dir/fake-git" "$bin_dir/fake-sudo"
printf '%s\n' "$fixture_root"
}
run_install() {
local repo_root="$1"
local home_dir="$2"
shift 2
HOME="$home_dir" \
GIT_BIN="$repo_root/../bin/fake-git" \
SUDO_BIN="$repo_root/../bin/fake-sudo" \
"$@" \
"$repo_root/install.sh"
}
test_fresh_install_uses_home_target() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME"
[ -d "$home_dir/.local" ] || fail "Expected ~/.local directory"
[ ! -L "$home_dir/.local" ] || fail "Expected ~/.local to stay a real directory"
[ -d "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin directory"
[ ! -L "$home_dir/.local/bin" ] || fail "Expected ~/.local/bin to stay a real directory"
[ -x "$home_dir/.local/bin/proxy-client" ] || fail "Expected executable proxy-client CLI in HOME"
[ -x "$home_dir/.local/bin/start-chatgpt-proxy" ] || fail "Expected ChatGPT proxy launcher in HOME"
[ -f "$home_dir/.local/share/applications/chatgpt-proxy.desktop" ] \
|| fail "Expected ChatGPT Proxy desktop entry in HOME"
[ -d "$home_dir/.local/share" ] || fail "Expected ~/.local/share directory"
[ ! -L "$home_dir/.local/share" ] || fail "Expected ~/.local/share to stay a real directory"
[ -d "$home_dir/.local/share/applications" ] || fail "Expected applications directory"
[ ! -L "$home_dir/.local/share/applications" ] \
|| fail "Expected applications directory to stay real"
[ -d "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config directory"
[ ! -L "$home_dir/.config/proxy-client" ] || fail "Expected proxy-client config to stay outside the repository"
[ -f "$home_dir/.config/proxy-client/shell.bash" ] || fail "Expected proxy-client shell module"
[ -f "$home_dir/.config/proxy-client/examples/config.external" ] || fail "Expected external proxy config example"
[ -f "$home_dir/.config/proxy-client/examples/config.xray" ] || fail "Expected managed Xray config example"
assert_not_exists "$home_dir/.config/proxy-client/examples/config"
[ ! -e "$home_dir/README.md" ] || fail "Package README must not be stowed into HOME"
[ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink"
assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom"
assert_not_exists "$fixture/.bash_custom"
}
test_fresh_install_does_not_fold_config_into_kitty() {
local fixture repo_root home_dir target expected
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
# Неполный клон: kitty остаётся единственным stow-пакетом с каталогом .config.
mv "$repo_root/proxy-client" "$fixture/proxy-client-disabled"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -d "$home_dir/.config" ] || fail "Expected ~/.config directory"
[ ! -L "$home_dir/.config" ] || fail "Expected ~/.config to stay a real directory"
# Каталог остаётся настоящим, симлинки ставятся по файлам: kitty пишет в
# ~/.config/kitty свои файлы, и они не должны попадать в репозиторий.
[ -d "$home_dir/.config/kitty" ] || fail "Expected ~/.config/kitty directory"
[ ! -L "$home_dir/.config/kitty" ] || fail "Expected ~/.config/kitty to stay a real directory"
[ -L "$home_dir/.config/kitty/kitty.conf" ] || fail "Expected ~/.config/kitty/kitty.conf symlink"
[ -L "$home_dir/.config/kitty/current-theme.conf" ] || fail "Expected ~/.config/kitty/current-theme.conf symlink"
target="$(readlink -f "$home_dir/.config/kitty/kitty.conf")"
expected="$(readlink -f "$repo_root/kitty/.config/kitty/kitty.conf")"
[ "$target" = "$expected" ] || fail "~/.config/kitty/kitty.conf points to $target, expected $expected"
# Файл, созданный kitty рядом с управляемыми, остаётся вне репозитория.
printf '%s\n' '# backup' > "$home_dir/.config/kitty/kitty.conf.bak"
assert_not_exists "$repo_root/kitty/.config/kitty/kitty.conf.bak"
}
test_existing_kitty_config_is_backed_up_and_does_not_abort_stow() {
local fixture repo_root home_dir backup
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.config/kitty"
# kitty создаёт конфиг сам, поэтому на чужой машине файл уже существует.
printf '%s\n' 'font_size 14' > "$home_dir/.config/kitty/kitty.conf"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -L "$home_dir/.config/kitty/kitty.conf" ] || fail "Expected managed kitty.conf symlink"
backup="$(find "$home_dir/.config/kitty" -maxdepth 1 -name 'kitty.conf.bak.*' -print -quit)"
[ -n "$backup" ] || fail "Expected backup of the pre-existing kitty.conf"
assert_file_contains "$backup" "font_size 14"
# Конфликт в одном пакете не должен обрывать stow целиком.
[ -L "$home_dir/.vimrc" ] || fail "Expected other packages to be stowed as well"
[ -L "$home_dir/.bash_custom" ] || fail "Expected install to continue past stow"
}
test_foreign_kitty_config_symlink_is_backed_up() {
local fixture repo_root home_dir backup target
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.config/kitty" "$fixture/elsewhere"
# Чужой симлинк для stow такой же конфликт, как обычный файл.
printf '%s\n' 'font_size 99' > "$fixture/elsewhere/my-kitty.conf"
ln -s "$fixture/elsewhere/my-kitty.conf" "$home_dir/.config/kitty/kitty.conf"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
target="$(readlink -f "$home_dir/.config/kitty/kitty.conf")"
[ "$target" = "$(readlink -f "$repo_root/kitty/.config/kitty/kitty.conf")" ] \
|| fail "Expected managed kitty.conf, got $target"
backup="$(find "$home_dir/.config/kitty" -maxdepth 1 -name 'kitty.conf.bak.*' -print -quit)"
[ -n "$backup" ] || fail "Expected backup of the foreign kitty.conf symlink"
[ -L "$home_dir/.vimrc" ] || fail "Expected other packages to be stowed as well"
# Исходный файл за симлинком остаётся нетронутым.
assert_file_contains "$fixture/elsewhere/my-kitty.conf" "font_size 99"
}
test_rerun_does_not_backup_managed_kitty_config() {
local fixture repo_root home_dir backup
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install2.out"
[ -L "$home_dir/.config/kitty/kitty.conf" ] || fail "Expected managed kitty.conf symlink"
backup="$(find "$home_dir/.config/kitty" -maxdepth 1 -name '*.bak.*' -print -quit)"
[ -z "$backup" ] || fail "Rerun must not create backups of its own symlinks: $backup"
}
test_existing_wezterm_config_is_backed_up() {
local fixture repo_root home_dir backup
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
# Тот же конфликт, но у пакета с файлом прямо в HOME, а не в ~/.config.
printf '%s\n' 'local wezterm = require("wezterm")' > "$home_dir/.wezterm.lua"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -L "$home_dir/.wezterm.lua" ] || fail "Expected managed .wezterm.lua symlink"
backup="$(find "$home_dir" -maxdepth 1 -name '.wezterm.lua.bak.*' -print -quit)"
[ -n "$backup" ] || fail "Expected backup of the pre-existing .wezterm.lua"
assert_file_contains "$backup" 'require("wezterm")'
}
test_codex_runtime_dir_is_unmanaged() {
local fixture repo_root home_dir backup
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
# ~/.codex — обычный каталог с рантайм-данными Codex; install.sh его не трогает.
mkdir -p "$home_dir/.codex"
printf '%s\n' '{}' > "$home_dir/.codex/auth.json"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install2.out"
[ -d "$home_dir/.codex" ] || fail "Expected ~/.codex to stay a directory"
[ ! -L "$home_dir/.codex" ] || fail "~/.codex must not become a symlink"
backup="$(find "$repo_root" "$home_dir" -name '*.bak.*' -print -quit)"
[ -z "$backup" ] || fail "Codex runtime files must not be backed up: $backup"
assert_file_contains "$home_dir/.codex/auth.json" "{}"
}
test_codex_env_is_created_when_missing() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -f "$home_dir/.codex/.env" ] && [ ! -L "$home_dir/.codex/.env" ] \
|| fail "Expected ~/.codex/.env to be a regular file"
cmp -s "$repo_root/codex/proxy.env" "$home_dir/.codex/.env" \
|| fail "Expected ~/.codex/.env to match codex/proxy.env"
}
test_codex_env_is_kept_when_present() {
local fixture repo_root home_dir backup
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
# Машина, где Codex ходит без прокси: свой .env установщик не трогает.
mkdir -p "$home_dir/.codex"
printf '%s\n' 'HTTPS_PROXY=' > "$home_dir/.codex/.env"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ "$(cat "$home_dir/.codex/.env")" = 'HTTPS_PROXY=' ] \
|| fail "Expected existing ~/.codex/.env to stay unchanged"
backup="$(find "$home_dir/.codex" -name '.env.bak.*' -print -quit)"
[ -z "$backup" ] || fail "Existing ~/.codex/.env must not be backed up: $backup"
}
claude_env_value() {
python3 -I -c 'import json, sys; print(json.load(open(sys.argv[1]))["env"].get(sys.argv[2], "<нет>"))' "$1" "$2"
}
test_claude_proxy_is_added_to_new_settings() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ "$(claude_env_value "$home_dir/.claude/settings.json" HTTPS_PROXY)" = 'http://127.0.0.1:12334' ] \
|| fail "Expected HTTPS_PROXY in new ~/.claude/settings.json"
[ "$(stat -c %a "$home_dir/.claude/settings.json")" = 600 ] \
|| fail "Expected new ~/.claude/settings.json to be private"
}
test_claude_proxy_is_merged_into_existing_settings() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude"
printf '%s\n' '{"theme": "dark", "env": {"FOO": "bar"}}' > "$home_dir/.claude/settings.json"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ "$(claude_env_value "$home_dir/.claude/settings.json" no_proxy)" != '<нет>' ] \
|| fail "Expected no_proxy to be merged into ~/.claude/settings.json"
[ "$(claude_env_value "$home_dir/.claude/settings.json" FOO)" = bar ] \
|| fail "Expected existing env keys to be preserved"
assert_file_contains "$home_dir/.claude/settings.json" '"theme": "dark"'
}
test_claude_existing_proxy_choice_is_kept() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
# Машина без прокси: достаточно одной proxy-переменной, чтобы шаблон не применялся.
mkdir -p "$home_dir/.claude"
printf '%s\n' '{"env": {"no_proxy": "*"}}' > "$home_dir/.claude/settings.json"
printf '%s\n' '{not json' > "$fixture/broken.json"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ "$(cat "$home_dir/.claude/settings.json")" = '{"env": {"no_proxy": "*"}}' ] \
|| fail "Expected settings with a proxy choice to stay unchanged"
cp "$fixture/broken.json" "$home_dir/.claude/settings.json"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install2.out"
cmp -s "$fixture/broken.json" "$home_dir/.claude/settings.json" \
|| fail "Expected invalid settings.json to stay unchanged"
grep -Fq 'не разобран как JSON' "$fixture/install2.out" \
|| fail "Expected warning about invalid settings.json"
}
test_migrates_folded_codex_symlink() {
local fixture repo_root home_dir legacy_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
# Старая схема: ~/.codex — симлинк на каталог внутри репозитория с рантайм-данными
legacy_dir="$fixture/legacy-repo-codex"
mkdir -p "$legacy_dir"
printf '%s\n' '{"token":"x"}' > "$legacy_dir/auth.json"
printf '%s\n' 'HTTPS_PROXY=http://hproxy.local' > "$legacy_dir/.env"
ln -s "$legacy_dir" "$home_dir/.codex"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
[ -d "$home_dir/.codex" ] || fail "Expected ~/.codex to become a directory"
[ ! -L "$home_dir/.codex" ] || fail "Expected ~/.codex to stop being a symlink"
assert_file_contains "$home_dir/.codex/auth.json" '"token":"x"'
assert_file_contains "$home_dir/.codex/.env" "hproxy.local"
}
test_claude_stale_artifacts_are_removed() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude"
# Артефакты прежнего установщика: симлинки на удалённые из репозитория файлы
# и наша копия statusline (узнаётся по строке-маркеру).
ln -s "$repo_root/claude/.claude/CLAUDE.md" "$home_dir/.claude/CLAUDE.md"
ln -s "$repo_root/claude/.claude/settings.local.json" "$home_dir/.claude/settings.local.json"
printf '#!/usr/bin/env bash\n# Статусная строка Claude Code. JSON приходит на stdin.\n' \
> "$home_dir/.claude/statusline-command.sh"
# Чужой файл с похожим именем трогать нельзя
printf 'echo mine\n' > "$home_dir/.claude/statusline-mine.sh"
chmod +x "$home_dir/.claude/statusline-command.sh"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
assert_not_exists "$home_dir/.claude/CLAUDE.md"
assert_not_exists "$home_dir/.claude/settings.local.json"
assert_not_exists "$home_dir/.claude/statusline-command.sh"
assert_file_contains "$home_dir/.claude/statusline-mine.sh" "echo mine"
[ -L "$home_dir/.claude/skills" ] || fail "Expected skills symlink to be created"
}
test_migrates_legacy_zed_config_symlink() {
local fixture repo_root home_dir legacy_config output mode
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
legacy_config="$repo_root/zed/.config"
mkdir -p "$home_dir" "$legacy_config/btop" "$legacy_config/proxy-client"
printf '%s\n' 'btop settings' > "$legacy_config/btop/btop.conf"
printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url"
chmod 600 "$legacy_config/proxy-client/default.url"
ln -s "$legacy_config" "$home_dir/.config"
if ! output="$(
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never
)"; then
fail "Expected legacy ~/.config migration to complete"
fi
[ -d "$home_dir/.config" ] || fail "Expected migrated ~/.config directory"
[ ! -L "$home_dir/.config" ] || fail "Expected migrated ~/.config to stop being a symlink"
# zed больше не stow-пакет (см. PACKAGES), поэтому после миграции каталог
# zed остаётся в репозитории и в ~/.config не появляется.
assert_not_exists "$home_dir/.config/zed"
[ -d "$home_dir/.config/btop" ] || fail "Expected btop config to be migrated"
[ ! -L "$home_dir/.config/btop" ] || fail "Expected btop config outside the repository"
[ -f "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile to be migrated"
[ ! -L "$home_dir/.config/proxy-client/default.url" ] || fail "Expected profile outside the repository"
mode="$(stat -c '%a' "$home_dir/.config/proxy-client/default.url")"
[ "$mode" = "600" ] || fail "Expected profile mode 600, got $mode"
assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL"
assert_not_exists "$legacy_config/btop"
assert_not_exists "$legacy_config/proxy-client"
if printf '%s' "$output" | grep -Fq 'VLESS_PROFILE_SENTINEL'; then
fail "Secret profile leaked into install output"
fi
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out"
assert_file_contains "$home_dir/.config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL"
}
test_stops_legacy_migration_when_listing_fails() {
local fixture repo_root home_dir legacy_config target expected
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
legacy_config="$repo_root/zed/.config"
mkdir -p "$home_dir" "$legacy_config/proxy-client"
printf '%s\n' 'VLESS_PROFILE_SENTINEL' > "$legacy_config/proxy-client/default.url"
ln -s "$legacy_config" "$home_dir/.config"
cat > "$fixture/bin/find" <<'EOF'
#!/usr/bin/env bash
exit 7
EOF
chmod +x "$fixture/bin/find"
if run_install "$repo_root" "$home_dir" \
env PATH="$fixture/bin:$PATH" WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never \
>"$fixture/install.out" 2>&1; then
fail "Expected migration to stop when legacy config listing fails"
fi
[ -L "$home_dir/.config" ] || fail "Expected legacy ~/.config symlink after listing failure"
target="$(readlink -f "$home_dir/.config")"
expected="$(readlink -f "$legacy_config")"
[ "$target" = "$expected" ] || fail "Legacy ~/.config target changed after listing failure"
assert_file_contains "$legacy_config/proxy-client/default.url" "VLESS_PROFILE_SENTINEL"
}
test_does_not_migrate_unrelated_config_symlink() {
local fixture repo_root home_dir unrelated_config target expected
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
unrelated_config="$fixture/unrelated-config"
mkdir -p "$home_dir" "$unrelated_config"
printf '%s\n' 'keep me' > "$unrelated_config/local.conf"
ln -s "$unrelated_config" "$home_dir/.config"
# Stow вправе сообщить о конфликте, но установщик не должен раскрывать
# произвольную ссылку и переносить чужие данные.
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true
[ -L "$home_dir/.config" ] || fail "Expected unrelated ~/.config symlink to be preserved"
target="$(readlink -f "$home_dir/.config")"
expected="$(readlink -f "$unrelated_config")"
[ "$target" = "$expected" ] || fail "Unrelated ~/.config symlink target changed"
assert_file_contains "$unrelated_config/local.conf" "keep me"
assert_not_exists "$unrelated_config/proxy-client"
}
test_does_not_follow_unrelated_local_symlink() {
local fixture repo_root home_dir unrelated_local target expected
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
unrelated_local="$fixture/unrelated-local"
mkdir -p "$home_dir" "$unrelated_local"
printf '%s\n' 'keep me' > "$unrelated_local/local.conf"
ln -s "$unrelated_local" "$home_dir/.local"
# Установщик не должен создавать bin через произвольную ссылку ~/.local.
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >"$fixture/install.out" 2>&1 || true
[ -L "$home_dir/.local" ] || fail "Expected unrelated ~/.local symlink to be preserved"
target="$(readlink -f "$home_dir/.local")"
expected="$(readlink -f "$unrelated_local")"
[ "$target" = "$expected" ] || fail "Unrelated ~/.local target changed"
assert_file_contains "$unrelated_local/local.conf" "keep me"
assert_not_exists "$unrelated_local/bin"
}
test_stow_migration_preserves_legacy_files() {
local fixture repo_root home_dir stow_target
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
# Simulate old stow layout: ~/.claude → repo dir
stow_target="$repo_root/claude/.claude"
ln -s "$stow_target" "$home_dir/.claude"
# Put a legacy local file in the stow target (раньше settings.local.json
# был git-tracked и пропускался; теперь это машинные данные — переносим)
echo '{"model":"opus"}' > "$stow_target/legacy-local.json"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -d "$home_dir/.claude" ] || fail "Expected ~/.claude to become a directory"
[ ! -L "$home_dir/.claude" ] || fail "Expected ~/.claude to stop being a symlink"
# legacy-local.json should have been moved from stow dir to real ~/.claude
[ -f "$home_dir/.claude/legacy-local.json" ] || fail "Expected legacy file to be migrated"
}
test_rerun_does_not_duplicate_bashrc_line() {
local fixture repo_root home_dir count
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
count="$(grep -Fxc '[ -f ~/.bash_custom ] && source ~/.bash_custom' "$home_dir/.bashrc")"
[ "$count" -eq 1 ] || fail "Expected bash_custom line once, got $count"
}
test_repairs_nested_skills_directory_bug() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude/skills"
ln -s "$repo_root/agents/.agents/skills" "$home_dir/.claude/skills/skills"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.claude/skills" ] || fail "Expected repaired ~/.claude/skills symlink"
assert_not_exists "$home_dir/.claude/skills/skills"
}
test_preserves_nonempty_skills_directory() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.claude/skills"
echo local > "$home_dir/.claude/skills/local.txt"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -d "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills directory to be preserved"
[ -f "$home_dir/.claude/skills/local.txt" ] || fail "Expected local skill data to be preserved"
}
test_opencode_runtime_dir_is_preserved_and_config_is_managed() {
local fixture repo_root home_dir backup_path
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir/.config/opencode"
echo runtime > "$home_dir/.config/opencode/package.json"
cat > "$home_dir/.config/opencode/opencode.json" <<'EOF'
{"source":"local"}
EOF
# Артефакт прежнего установщика: симлинк на удалённые профили агентов
ln -s "$repo_root/opencode/.config/opencode/agents" "$home_dir/.config/opencode/agents"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -f "$home_dir/.config/opencode/package.json" ] || fail "Expected opencode runtime files to be preserved"
assert_not_exists "$home_dir/.config/opencode/agents"
[ -L "$home_dir/.config/opencode/skills" ] || fail "Expected ~/.config/opencode/skills symlink"
assert_file_contains "$home_dir/.config/opencode/opencode.json" '"$schema": "https://opencode.ai/config.json"'
assert_file_contains "$home_dir/.config/opencode/permission-reviewer.jsonc" '"confidenceThreshold"'
backup_path="$(find "$home_dir/.config/opencode" -maxdepth 1 -name 'opencode.json.bak.*' -print -quit)"
[ -n "$backup_path" ] || fail "Expected backup for existing opencode.json"
assert_file_contains "$backup_path" '"source":"local"'
}
test_broken_claude_symlink_does_not_fail() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir"
ln -s "$fixture/missing-claude-dir" "$home_dir/.claude"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -d "$home_dir/.claude" ] || fail "Expected ~/.claude directory after migration"
[ ! -L "$home_dir/.claude" ] || fail "Expected ~/.claude to stop being a symlink"
}
test_absolute_symlinks_in_home_do_not_break_install() {
local fixture repo_root home_dir
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
mkdir -p "$home_dir" "$fixture/windows/.aws" "$fixture/windows/.azure"
ln -s "$fixture/windows/.aws" "$home_dir/.aws"
ln -s "$fixture/windows/.azure" "$home_dir/.azure"
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.aws" ] || fail "Expected ~/.aws symlink to survive install"
[ -L "$home_dir/.azure" ] || fail "Expected ~/.azure symlink to survive install"
}
test_wsl_noninteractive_skips_file_changes() {
local fixture repo_root home_dir wsl_conf output
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
printf '[automount]\n' > "$wsl_conf"
output="$(
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=never WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" "[automount]"
if grep -Fq '[boot]' "$wsl_conf"; then
fail "WSL config should not be modified in non-interactive mode"
fi
printf '%s' "$output" | grep -Fq 'Добавьте вручную:' || fail "Expected manual WSL hint"
}
test_wsl_noninteractive_existing_boot_command_is_preserved() {
local fixture repo_root home_dir wsl_conf output
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
cat > "$wsl_conf" <<'EOF'
[boot]
command = "/usr/local/bin/existing.sh"
EOF
output="$(
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=never WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" 'command = "/usr/local/bin/existing.sh"'
if grep -Fq 'setup-hproxy.sh' "$wsl_conf"; then
fail "Expected existing boot command to stay untouched"
fi
printf '%s' "$output" | grep -Fq 'уже содержит [boot] command' || fail "Expected warning about existing boot command"
if printf '%s' "$output" | grep -Fq 'Добавьте вручную:'; then
fail "Did not expect manual add hint when [boot] command already exists"
fi
}
test_wsl_legacy_boot_command_suggests_migration() {
local fixture repo_root home_dir wsl_conf output command_line
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
cat > "$wsl_conf" <<'EOF'
[boot]
command = "ip route show default | awk '{print $3 \" hproxy.local\"}' >> /etc/hosts"
EOF
command_line="command = \"$repo_root/scripts/setup-hproxy.sh\""
output="$(
run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=never WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" 'command = "ip route show default | awk '\''{print $3 \" hproxy.local\"}'\'' >> /etc/hosts"'
printf '%s' "$output" | grep -Fq 'legacy boot command' || fail "Expected legacy boot command warning"
printf '%s' "$output" | grep -Fq 'Рекомендуется заменить' || fail "Expected migration hint to replace legacy command"
printf '%s' "$output" | grep -Fq "$command_line" || fail "Expected suggested setup-hproxy.sh command"
}
test_wsl_interactive_adds_boot_command() {
local fixture repo_root home_dir wsl_conf command_line
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
printf '[automount]\n' > "$wsl_conf"
command_line="command = \"$repo_root/scripts/setup-hproxy.sh\""
printf 'y\n' | run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=always WSL_CONF_PATH="$wsl_conf" >/tmp/test-install.out
assert_file_contains "$wsl_conf" "[boot]"
assert_file_contains "$wsl_conf" "$command_line"
}
test_wsl_existing_boot_command_is_preserved() {
local fixture repo_root home_dir wsl_conf output
fixture="$(setup_fixture)"
repo_root="$fixture/repo"
home_dir="$fixture/home"
wsl_conf="$fixture/wsl.conf"
mkdir -p "$home_dir"
cat > "$wsl_conf" <<'EOF'
[boot]
command = "/usr/local/bin/existing.sh"
EOF
output="$(
printf 'y\n' | run_install "$repo_root" "$home_dir" \
env WSL_DISTRO_NAME=Ubuntu INSTALL_INTERACTIVE_MODE=always WSL_CONF_PATH="$wsl_conf"
)"
assert_file_contains "$wsl_conf" 'command = "/usr/local/bin/existing.sh"'
if grep -Fq 'setup-hproxy.sh' "$wsl_conf"; then
fail "Expected existing boot command to stay untouched"
fi
printf '%s' "$output" | grep -Fq 'уже содержит [boot] command' || fail "Expected warning about existing boot command"
}
main() {
command -v stow >/dev/null 2>&1 || fail "stow is required for tests"
test_fresh_install_uses_home_target
test_fresh_install_does_not_fold_config_into_kitty
test_existing_kitty_config_is_backed_up_and_does_not_abort_stow
test_foreign_kitty_config_symlink_is_backed_up
test_rerun_does_not_backup_managed_kitty_config
test_existing_wezterm_config_is_backed_up
test_codex_runtime_dir_is_unmanaged
test_codex_env_is_created_when_missing
test_codex_env_is_kept_when_present
test_claude_proxy_is_added_to_new_settings
test_claude_proxy_is_merged_into_existing_settings
test_claude_existing_proxy_choice_is_kept
test_migrates_folded_codex_symlink
test_claude_stale_artifacts_are_removed
test_migrates_legacy_zed_config_symlink
test_stops_legacy_migration_when_listing_fails
test_does_not_migrate_unrelated_config_symlink
test_does_not_follow_unrelated_local_symlink
test_stow_migration_preserves_legacy_files
test_rerun_does_not_duplicate_bashrc_line
test_repairs_nested_skills_directory_bug
test_preserves_nonempty_skills_directory
test_opencode_runtime_dir_is_preserved_and_config_is_managed
test_broken_claude_symlink_does_not_fail
test_absolute_symlinks_in_home_do_not_break_install
test_wsl_noninteractive_skips_file_changes
test_wsl_noninteractive_existing_boot_command_is_preserved
test_wsl_legacy_boot_command_suggests_migration
test_wsl_interactive_adds_boot_command
test_wsl_existing_boot_command_is_preserved
echo "All install tests passed"
}
main "$@"