feat(installer): improve UI and add test coverage for core features

- Refactored installer UI with updated canvas rendering, sidebar navigation, and footer buttons.
- Enhanced styling for macOS compatibility and consistent design across controls.
- Added Pytest-based unit tests for `screen.py` and `config.py`.
- Expanded dependency catalog with new tools like `tshark` and `pyshark`.
- Improved error tolerance for background rendering and added placeholders for Kerberos configuration.
This commit is contained in:
chrisfu 2026-01-08 21:51:30 -08:00
parent 7b71d80053
commit 906392d462
19 changed files with 1506 additions and 379 deletions

11
.coveragerc Normal file
View File

@ -0,0 +1,11 @@
[run]
source =
install.py
installer/
omit =
tests/*
*/__init__.py
[report]
show_missing = True

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
/.DS_Store
/.gitignore
/__pycache__/
/bin/*
!/bin/prole-env.sh
!/bin/prole-kpf.sh
/lib/
/include/
/.idea/
/.vagrant/
/.vscode/
/.venv/
/prole-tools-app/dist/
/prole-tools-app/.build/
/prole-tools-app/.build-cli/
# Secrets and local config
*-password.txt
*secret.yaml
/secrets/
/conf/
/data/
/logs/
/storage/
/target/
/pyvenv.cfg
.output.txt
deploy/gcp/terraform-setup.txt
.terraform/
.terraform.lock.hcl
# Large binaries
*.zip
*.tar.gz
# Coverage and testing
.coverage
htmlcov/
.pytest_cache/
# Unreal Engine artifacts
/workstation/Prole/Binaries/
/workstation/Prole/Intermediate/
/workstation/Prole/Saved/
/workstation/Prole/DerivedDataCache/
/workstation/Prole/Build/

11
bin/prole-env.sh Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Prole environment file. Sourced by other scripts in ~/.prole/bin.
# Customize PATH, KUBECONFIG, contexts, etc.
# Ensure common kubectl locations are available
export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:$PATH"
# Example: point to a specific kubeconfig if needed
# export KUBECONFIG="$HOME/.kube/config"
# You can add any environment variables that your port-forward commands require here.

76
bin/prole-kpf.sh Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
LABEL="org.prole.prole-db.kpf-dev"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
PROLE_HOME="$HOME/.prole"
BIN_DIR="$PROLE_HOME/bin"
RUN_DIR="$PROLE_HOME/run"
PIDFILE="$RUN_DIR/kpf.pids"
# Source environment if present
if [ -f "$BIN_DIR/prole-env.sh" ]; then
# shellcheck source=/dev/null
. "$BIN_DIR/prole-env.sh"
fi
ensure_dirs() {
mkdir -p "$BIN_DIR" "$RUN_DIR"
}
list_cmds() {
if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then
local i=0
while true; do
if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then
break
fi
echo "$val"
i=$((i+1))
done
fi
}
start() {
ensure_dirs
: > "$PIDFILE"
# Iterate over commands and start each in background shell
while IFS= read -r cmd; do
[ -z "$cmd" ] && continue
(sh -lc "$cmd") &
echo $! >> "$PIDFILE"
done < <(list_cmds)
wait || true
}
stop() {
if [ -f "$PIDFILE" ]; then
while read -r pid; do
[ -z "$pid" ] && continue
kill "$pid" 2>/dev/null || true
done < "$PIDFILE"
rm -f "$PIDFILE"
fi
}
status() {
if [ ! -f "$PIDFILE" ]; then
echo "not running"
exit 3
fi
local alive=0 total=0
while read -r pid; do
[ -z "$pid" ] && continue
total=$((total+1))
if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi
done < "$PIDFILE"
echo "$alive/$total running"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
esac

View File

@ -280,8 +280,8 @@ case "$ACTION" in
echo "ERROR: CNPG manifest not found at $CNPG_MANIFEST" >&2
exit 1
fi
echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST ..."
kubectl apply -f "$CNPG_MANIFEST"
echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST in namespace $NAMESPACE..."
kubectl apply -n "$NAMESPACE" -f "$CNPG_MANIFEST"
;;
stop)
ensure_tools

View File

@ -33,9 +33,8 @@ fi
ACTION=${1:-}
# Defaults
NAMESPACE=${NAMESPACE:-default}
NAMESPACE=${NAMESPACE:-prole}
OPENBAO_NAME=${OPENBAO_NAME:-openbao}
OPENBAO_IMAGE=${OPENBAO_IMAGE:-ghcr.io/openbao/openbao:latest}
OPENBAO_MANIFEST_DIR="$SCRIPT_DIR/../k8s/openbao"
# Kerberos realm defaults
@ -167,14 +166,23 @@ EOF
apply_k8s() {
echo "Applying OpenBao manifest to namespace '$NAMESPACE' ..."
kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml"
if [[ -f "$SCRIPT_DIR/../k8s/prole/openbao-statefulset.yaml" ]]; then
kubectl apply -n "$NAMESPACE" -f "$SCRIPT_DIR/../k8s/prole/openbao-statefulset.yaml"
kubectl apply -n "$NAMESPACE" -f "$SCRIPT_DIR/../k8s/prole/openbao-service.yaml"
else
kubectl apply -n "$NAMESPACE" -f "$OPENBAO_MANIFEST_DIR/deployment.yaml"
fi
echo "Applying Kerberos ConfigMap (external realm) to namespace '$NAMESPACE' ..."
kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml"
kubectl apply -n "$NAMESPACE" -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml"
}
wait_for_openbao() {
echo "Waiting for OpenBao to become ready ..."
if kubectl get statefulset/$OPENBAO_NAME -n "$NAMESPACE" >/dev/null 2>&1; then
kubectl rollout status statefulset/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s
else
kubectl rollout status deploy/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s
fi
}
init_openbao_kv_and_store_admin_key() {
@ -282,7 +290,19 @@ cmd_status() {
fi
# K8s resources
if kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" >/dev/null 2>&1; then
if kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" >/dev/null 2>&1; then
local ready desired
ready=$(kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0")
desired=$(kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" -o jsonpath='{.status.replicas}' 2>/dev/null || echo "0")
ready=${ready:-0}
desired=${desired:-0}
if [[ "$ready" == "$desired" && "$ready" != "0" ]]; then
echo "[OK] OpenBao StatefulSet running ($ready/$desired ready)"
else
echo "[WARN] OpenBao StatefulSet not fully ready ($ready/$desired)"
ok=1
fi
elif kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" >/dev/null 2>&1; then
# Get readiness
local ready desired
ready=$(kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0")
@ -296,7 +316,7 @@ cmd_status() {
ok=1
fi
else
echo "[MISSING] OpenBao Deployment '$OPENBAO_NAME' in namespace '$NAMESPACE'"
echo "[MISSING] OpenBao Deployment or StatefulSet '$OPENBAO_NAME' in namespace '$NAMESPACE'"
ok=1
fi
@ -416,13 +436,13 @@ case "$ACTION" in
kubectl create secret generic prole-db-user -n "$NAMESPACE" \
--from-literal=username=prole \
--from-literal=password="$db_pass" \
--dry-run=client -o yaml | kubectl apply -f -
--dry-run=client -o yaml | kubectl apply -n "$NAMESPACE" -f -
echo "Creating database superuser secret 'prole-db-superuser' ..."
kubectl create secret generic prole-db-superuser -n "$NAMESPACE" \
--from-literal=username=postgres \
--from-literal=password="$db_pass" \
--dry-run=client -o yaml | kubectl apply -f -
--dry-run=client -o yaml | kubectl apply -n "$NAMESPACE" -f -
fi
wait_for_openbao
@ -442,8 +462,7 @@ case "$ACTION" in
update|reload)
generate_openbao_manifests
generate_kerberos_configmap
kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml"
kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml"
apply_k8s
echo "Re-applied manifests."
;;
*)

View File

@ -6,7 +6,7 @@ set -u
# Manages kubectl port-forward daemons defined in an XML file.
PROG="init_port_forwards"
PROLE_HOME="/Users/chrisfu/dev/prole"
PROLE_HOME="${PROLE_HOME:-/Users/chrisfu/dev/prole}"
VERBOSE=0
CONFIG_FILE="$PROLE_HOME/conf/port-mappings.properties"

1352
install.py

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ from __future__ import annotations
from pathlib import Path
import platform
import subprocess
import sys
from typing import Tuple, Optional
import os
@ -171,6 +172,24 @@ DEPENDENCIES = [
"check_cmd": "ollama --version",
"bin": "ollama",
},
{
"id": "tshark",
"name": "tshark (Wireshark)",
"description": "Network protocol analyzer for network scanning",
"url": "https://www.wireshark.org",
"install_cmd": "brew install wireshark" if platform.system() == 'Darwin' else "sudo apt-get update && sudo apt-get install -y tshark",
"check_cmd": "tshark --version",
"bin": "tshark",
},
{
"id": "pyshark",
"name": "pyshark",
"description": "Python wrapper for tshark",
"url": "https://github.com/KimiNewt/pyshark",
"install_cmd": f"'{sys.executable}' -m pip install pyshark",
"check_cmd": f"'{sys.executable}' -c 'import pyshark'",
"bin": None,
},
]
@ -190,6 +209,10 @@ def get_dep_info(dep: dict) -> Tuple[bool, Optional[str], Optional[str]]:
res2 = subprocess.run(["bash", "-lc", check_cmd], capture_output=True, text=True)
if res2.returncode == 0:
version = " ".join(res2.stdout.strip().splitlines()[:1])
# If check_cmd succeeded, consider it installed
installed = True
if not location:
location = "Installed via Python"
except Exception:
pass
return installed, location, version

View File

@ -15,20 +15,20 @@ def render_title(app, text: str, y: int = 40):
"""Render a section title on the main background canvas."""
if getattr(app, 'bg_canvas', None) is None:
return
item = app.bg_canvas.create_text(40, y, anchor='nw', text=text, fill='#111', font=('Helvetica Neue', 22, 'bold'))
item = app.bg_canvas.create_text(48, y, anchor='nw', text=text, fill='black', font=('SF Pro Text', 18, 'bold'))
app._canvas_items.append(item)
def render_paragraph(app, text: str, y: int, wrap: int = 860):
def render_paragraph(app, text: str, y: int, wrap: int = 800):
"""Render a paragraph on the main background canvas."""
if getattr(app, 'bg_canvas', None) is None:
return
item = app.bg_canvas.create_text(40, y, anchor='nw', text=text, fill='#1d1d1f', font=('Helvetica', 12), width=wrap)
item = app.bg_canvas.create_text(48, y, anchor='nw', text=text, fill='black', font=('SF Pro Text', 11), width=wrap)
app._canvas_items.append(item)
def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f',
font: tuple = ('Helvetica', 12), anchor: str = 'nw', width: int | None = None,
def canvas_text(app, x: int, y: int, text: str, *, fill: str = 'black',
font: tuple = ('SF Pro Text', 11), anchor: str = 'nw', width: int | None = None,
justify: str | None = None, state: str | None = None) -> int:
"""Create a text item on the app's main canvas and track it.
@ -73,7 +73,7 @@ def canvas_rectangle(app, x1: int, y1: int, x2: int, y2: int, *, outline: str =
return item
def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = '#1d1d1f',
def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = 'black',
width: int = 2, state: str | None = None) -> int:
if getattr(app, 'bg_canvas', None) is None:
return -1
@ -145,49 +145,45 @@ def canvas_line_on(cnv: tk.Canvas, x1: int, y1: int, x2: int, y2: int, *, fill:
def create_nav_footer(parent, buttons: list[tuple[int, str]], commands: dict[int, callable] | None = None,
style_name: str = 'Nav.TButton') -> dict[int, ttk.Button]:
style_name: str = 'Nav.TButton') -> dict[int, tk.Button]:
"""Create a right-aligned navigation footer with uniform button styling.
Parameters:
- parent: the container (typically the root installer container)
- buttons: list of (button_id, title) in the order to display
- commands: optional mapping of button_id -> callback function
- style_name: ttk style to apply to all buttons
Returns: {button_id: ttk.Button}
Examples:
create_nav_footer(footer, [(1, 'Next')])
create_nav_footer(footer, [(1, 'Prev'), (2, 'Next')])
create_nav_footer(footer, [(1, 'Exit')])
Uses tk.Button instead of ttk.Button for better color control on macOS.
"""
# Ensure a consistent style for navigation buttons
try:
style = ttk.Style()
# Keep padding modest to allow Aqua to size buttons naturally.
# Avoid forcing a font so the native Aqua metrics are used.
style.configure(style_name, padding=(10, 6))
except Exception:
pass
footer = ttk.Frame(parent)
footer = tk.Frame(parent, bg='#F5F5DC', height=64)
footer.pack(fill='x', side='bottom')
# Let the footer naturally size to its contents to avoid clipping text on macOS
footer.pack_propagate(False)
# Top divider line for the footer
divider = tk.Frame(footer, bg='#CCCCCC', height=1)
divider.pack(side='top', fill='x')
# Flexible spacer to push buttons to the right
spacer = ttk.Frame(footer)
spacer = tk.Frame(footer, bg='#F5F5DC')
spacer.pack(side='left', expand=True, fill='x')
cmds = commands or {}
btn_map: dict[int, ttk.Button] = {}
btn_map: dict[int, tk.Button] = {}
for btn_id, title in buttons:
cmd = cmds.get(btn_id)
b = ttk.Button(footer, text=title, style=style_name, command=cmd)
# Use tk.Button for full control over background and borders on macOS
b = tk.Button(footer,
text=title,
command=cmd,
bg='#F5F5DC',
fg='black',
activebackground='#E5E5D5',
activeforeground='black',
highlightbackground='#F5F5DC', # Essential for macOS to avoid black boxes
highlightthickness=0,
relief='flat',
font=('SF Pro Text', 11),
padx=16,
pady=8)
# Right-aligned order (pack to the right in the declared order)
# Small extra right padding on the last (right-most) button
pad = (0, 20) if title.lower() in ('finish', 'exit', 'done') else (0, 8)
# Use modest vertical padding; let Aqua compute proper height.
b.pack(side='right', padx=pad, pady=10)
pad = (0, 20) if title.lower() in ('finish', 'exit', 'done', 'next') else (0, 8)
b.pack(side='right', padx=pad, pady=12)
btn_map[btn_id] = b
# Return both the frame and button map if needed later by callers
@ -212,16 +208,25 @@ class TerminalConsole(ttk.Frame):
def write(self, content: str):
"""Append text to the console and scroll to the bottom."""
try:
if not self.winfo_exists():
return
self.text.configure(state='normal')
self.text.insert('end', content)
self.text.see('end')
self.text.configure(state='disabled')
self.update_idletasks()
except tk.TclError:
pass
def clear(self):
"""Clear all content from the console."""
try:
if not self.winfo_exists():
return
self.text.configure(state='normal')
self.text.delete('1.0', 'end')
self.text.configure(state='disabled')
self.update_idletasks()
except tk.TclError:
pass

View File

@ -2,7 +2,7 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: prole-krb5-conf
namespace: default
namespace: prole
data:
krb5.conf: |
[libdefaults]

View File

@ -4,7 +4,7 @@ metadata:
name: prole-db
spec:
instances: 3
imageName: prole-db:17.7-031
imageName: prole-db:17.7-033
postgresUID: 100
postgresGID: 101
maxSyncReplicas: 1
@ -24,6 +24,7 @@ spec:
- host prole prole-db all scram-sha-256
- host all all all scram-sha-256
- hostssl prole prole-db all scram-sha-256
- host all all all gss include_realm=1 krb_realm=EXAMPLE.COM
bootstrap:
initdb:

View File

@ -88,8 +88,8 @@ RUN set -eux; \
apt-get update; \
apt-cache search percona-postgresql-17 | grep -E "(pgvector|postgis|contrib)"; \
apt-get install -y --no-install-recommends \
percona-pgvector-percona-postgresql-17 \
percona-postgis-percona-postgresql-17 \
percona-postgresql-17-pgvector \
percona-postgresql-17-postgis-3 \
percona-postgresql-contrib-17 \
freetds-dev \
; \

15
run_tests.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# Run all unit tests and generate a coverage report
export PYTHONPATH=$PYTHONPATH:.
# Check if pytest-cov is installed
if pytest --trace-config | grep -q "pytest_cov"; then
echo "Running tests with coverage..."
pytest --cov=installer --cov=install --cov-report=term-missing --cov-report=html tests/
echo "Coverage report (HTML) generated in htmlcov/index.html"
else
echo "Warning: pytest-cov not found. Running tests without coverage."
echo "To enable coverage, install it via: pip install pytest-cov"
pytest tests/
fi

8
run_with_coverage.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# Run the Prole installer with execution instrumentation (coverage)
# This helps identify unused code paths during manual testing/usage.
export PYTHONPATH=$PYTHONPATH:.
coverage run install.py "$@"
coverage report -m
coverage html
echo "Execution instrumentation report generated in htmlcov/index.html"

View File

@ -0,0 +1,15 @@
from pathlib import Path
from installer.build import get_build_command
def test_get_build_command():
root = Path("/fake/root")
cmd = get_build_command(root, "Dev")
assert 'cd "/fake/root"' in cmd
assert "PROLE_VERBOSE=1" in cmd
assert "# Dev" in cmd
cmd_prod = get_build_command(root, "Prod")
assert "# Prod" in cmd_prod
cmd_none = get_build_command(root, None)
assert "# Dev" in cmd_none

View File

@ -0,0 +1,33 @@
import pytest
from unittest.mock import patch, MagicMock
from installer.config import normalize_version, is_apple_silicon, get_docker_build_platform_args
def test_normalize_version():
assert normalize_version("1.2.3") == "01.02.03"
assert normalize_version("v1.2") == "01.02.00"
assert normalize_version("2") == "02.00.00"
assert normalize_version("10.0.1-beta") == "10.00.01"
assert normalize_version("") == ""
assert normalize_version("no numbers") == "no numbers"
@patch("platform.machine")
@patch("platform.system")
def test_is_apple_silicon(mock_system, mock_machine):
mock_machine.return_value = "arm64"
mock_system.return_value = "Darwin"
assert is_apple_silicon() is True
mock_machine.return_value = "x86_64"
assert is_apple_silicon() is False
mock_machine.return_value = "arm64"
mock_system.return_value = "Linux"
assert is_apple_silicon() is False
@patch("installer.config.is_apple_silicon")
def test_get_docker_build_platform_args(mock_is_apple_silicon):
mock_is_apple_silicon.return_value = True
assert get_docker_build_platform_args() == ["--platform", "linux/amd64"]
mock_is_apple_silicon.return_value = False
assert get_docker_build_platform_args() == []

View File

@ -0,0 +1,48 @@
import pytest
from unittest.mock import MagicMock, patch
import tkinter as tk
from installer.screen import render_title, render_paragraph, TerminalConsole
@pytest.fixture
def mock_app():
app = MagicMock()
app.bg_canvas = MagicMock()
app._canvas_items = []
return app
def test_render_title(mock_app):
render_title(mock_app, "Test Title", y=50)
mock_app.bg_canvas.create_text.assert_called_once()
args, kwargs = mock_app.bg_canvas.create_text.call_args
assert args == (48, 50)
assert kwargs['text'] == "Test Title"
assert len(mock_app._canvas_items) == 1
def test_render_paragraph(mock_app):
render_paragraph(mock_app, "Test Paragraph", y=100)
mock_app.bg_canvas.create_text.assert_called_once()
args, kwargs = mock_app.bg_canvas.create_text.call_args
assert args == (48, 100)
assert kwargs['text'] == "Test Paragraph"
assert len(mock_app._canvas_items) == 1
def test_render_title_no_canvas():
app = MagicMock()
app.bg_canvas = None
render_title(app, "Title")
# Should not raise exception
@patch('platform.system', return_value='Darwin')
def test_terminal_console(mock_platform):
root = tk.Tk()
try:
console = TerminalConsole(root)
console.write("Hello\n")
# In mock or headless env, we might not be able to check text content easily
# but we can verify it doesn't crash and state is managed
assert console.text.cget('state') == 'disabled'
console.clear()
assert console.text.cget('state') == 'disabled'
finally:
root.destroy()

View File

@ -0,0 +1,62 @@
import sys
from unittest.mock import patch, MagicMock
# Mock tkinter and other GUI/macOS specific imports before they are imported in install.py
sys.modules['tkinter'] = MagicMock()
sys.modules['tkinter.ttk'] = MagicMock()
sys.modules['tkinter.scrolledtext'] = MagicMock()
sys.modules['tkinter.messagebox'] = MagicMock()
sys.modules['Foundation'] = MagicMock()
sys.modules['objc'] = MagicMock()
import os
import pytest
from pathlib import Path
# Now import install.py after mocking
import install
from install import ProleInstaller
@pytest.fixture
def installer(tmp_path):
# Mocking os.environ to avoid messing with real env
with patch.dict(os.environ, {}, clear=True):
# We need to mock ProleInstaller.__init__ because it creates GUI elements
with patch.object(ProleInstaller, '__init__', return_value=None):
ins = ProleInstaller()
return ins
def test_resolve_prole_home_default(installer):
with patch.dict(os.environ, {}, clear=True):
# Path.home() might vary, so we just check it ends with .prole if no env var
home = installer.resolve_prole_home()
assert home.name == ".prole"
def test_resolve_prole_home_env(installer):
custom_home = "/tmp/custom_prole"
with patch.dict(os.environ, {'PROLE_HOME': custom_home}):
home = installer.resolve_prole_home()
assert str(home) == custom_home
def test_env_defaults(installer):
defaults = installer._env_defaults()
assert 'PROLE_HOME' in defaults
assert defaults['PROLE_HOME'].endswith('.prole')
assert defaults['PROLE_CONF'].endswith('.prole/conf')
def test_read_existing_env_no_file(installer, tmp_path):
# Ensure neither PROLE_HOME nor the default location has an env.sh for this test
with patch.dict(os.environ, {'PROLE_HOME': str(tmp_path)}):
with patch('pathlib.Path.home', return_value=tmp_path / "fake_home"):
env = installer._read_existing_env()
assert env == {}
def test_read_existing_env_with_file(installer, tmp_path):
env_sh = tmp_path / "env.sh"
env_sh.write_text('export VAR1="val1"\nVAR2=val2\n# comment\n')
with patch.dict(os.environ, {'PROLE_HOME': str(tmp_path)}):
with patch('pathlib.Path.home', return_value=tmp_path / "fake_home"):
env = installer._read_existing_env()
assert env.get('VAR1') == "val1"
assert env.get('VAR2') == "val2"