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 echo "ERROR: CNPG manifest not found at $CNPG_MANIFEST" >&2
exit 1 exit 1
fi fi
echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST ..." echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST in namespace $NAMESPACE..."
kubectl apply -f "$CNPG_MANIFEST" kubectl apply -n "$NAMESPACE" -f "$CNPG_MANIFEST"
;; ;;
stop) stop)
ensure_tools ensure_tools

View File

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

View File

@ -6,7 +6,7 @@ set -u
# Manages kubectl port-forward daemons defined in an XML file. # Manages kubectl port-forward daemons defined in an XML file.
PROG="init_port_forwards" PROG="init_port_forwards"
PROLE_HOME="/Users/chrisfu/dev/prole" PROLE_HOME="${PROLE_HOME:-/Users/chrisfu/dev/prole}"
VERBOSE=0 VERBOSE=0
CONFIG_FILE="$PROLE_HOME/conf/port-mappings.properties" 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 from pathlib import Path
import platform import platform
import subprocess import subprocess
import sys
from typing import Tuple, Optional from typing import Tuple, Optional
import os import os
@ -171,6 +172,24 @@ DEPENDENCIES = [
"check_cmd": "ollama --version", "check_cmd": "ollama --version",
"bin": "ollama", "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) res2 = subprocess.run(["bash", "-lc", check_cmd], capture_output=True, text=True)
if res2.returncode == 0: if res2.returncode == 0:
version = " ".join(res2.stdout.strip().splitlines()[:1]) 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: except Exception:
pass pass
return installed, location, version 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.""" """Render a section title on the main background canvas."""
if getattr(app, 'bg_canvas', None) is None: if getattr(app, 'bg_canvas', None) is None:
return 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) 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.""" """Render a paragraph on the main background canvas."""
if getattr(app, 'bg_canvas', None) is None: if getattr(app, 'bg_canvas', None) is None:
return 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) app._canvas_items.append(item)
def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f', def canvas_text(app, x: int, y: int, text: str, *, fill: str = 'black',
font: tuple = ('Helvetica', 12), anchor: str = 'nw', width: int | None = None, font: tuple = ('SF Pro Text', 11), anchor: str = 'nw', width: int | None = None,
justify: str | None = None, state: str | None = None) -> int: justify: str | None = None, state: str | None = None) -> int:
"""Create a text item on the app's main canvas and track it. """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 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: width: int = 2, state: str | None = None) -> int:
if getattr(app, 'bg_canvas', None) is None: if getattr(app, 'bg_canvas', None) is None:
return -1 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, 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. """Create a right-aligned navigation footer with uniform button styling.
Parameters: Uses tk.Button instead of ttk.Button for better color control on macOS.
- 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')])
""" """
# Ensure a consistent style for navigation buttons footer = tk.Frame(parent, bg='#F5F5DC', height=64)
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.pack(fill='x', side='bottom') 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 # 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') spacer.pack(side='left', expand=True, fill='x')
cmds = commands or {} cmds = commands or {}
btn_map: dict[int, ttk.Button] = {} btn_map: dict[int, tk.Button] = {}
for btn_id, title in buttons: for btn_id, title in buttons:
cmd = cmds.get(btn_id) 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) # 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', 'next') else (0, 8)
pad = (0, 20) if title.lower() in ('finish', 'exit', 'done') else (0, 8) b.pack(side='right', padx=pad, pady=12)
# Use modest vertical padding; let Aqua compute proper height.
b.pack(side='right', padx=pad, pady=10)
btn_map[btn_id] = b btn_map[btn_id] = b
# Return both the frame and button map if needed later by callers # 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): def write(self, content: str):
"""Append text to the console and scroll to the bottom.""" """Append text to the console and scroll to the bottom."""
try:
if not self.winfo_exists():
return
self.text.configure(state='normal') self.text.configure(state='normal')
self.text.insert('end', content) self.text.insert('end', content)
self.text.see('end') self.text.see('end')
self.text.configure(state='disabled') self.text.configure(state='disabled')
self.update_idletasks() self.update_idletasks()
except tk.TclError:
pass
def clear(self): def clear(self):
"""Clear all content from the console.""" """Clear all content from the console."""
try:
if not self.winfo_exists():
return
self.text.configure(state='normal') self.text.configure(state='normal')
self.text.delete('1.0', 'end') self.text.delete('1.0', 'end')
self.text.configure(state='disabled') self.text.configure(state='disabled')
self.update_idletasks() self.update_idletasks()
except tk.TclError:
pass

View File

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

View File

@ -4,7 +4,7 @@ metadata:
name: prole-db name: prole-db
spec: spec:
instances: 3 instances: 3
imageName: prole-db:17.7-031 imageName: prole-db:17.7-033
postgresUID: 100 postgresUID: 100
postgresGID: 101 postgresGID: 101
maxSyncReplicas: 1 maxSyncReplicas: 1
@ -24,6 +24,7 @@ spec:
- host prole prole-db all scram-sha-256 - host prole prole-db all scram-sha-256
- host all all all scram-sha-256 - host all all all scram-sha-256
- hostssl prole prole-db 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: bootstrap:
initdb: initdb:

View File

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