detached c7ad5bd

fix(repos): build AUR helper as user to avoid permission issues

commit
c7ad5bd1dca1a1322b796cbf0f2911fd7878a224
author
bnema · · 2025-10-27 17:18 UTC
committer
bnema · · 2025-10-27 18:49 UTC
parent
5245050
signature
unsigned
29 files · +3640 -326
M .gitignore +1 -0
@@ -22,6 +22,7 @@ coverage.out
22 /*.md22 /*.md
23 !README.md23 !README.md
24 !CHANGELOG.md24 !CHANGELOG.md
25+!LICENSE.md
25 !RELEASING.md26 !RELEASING.md
26 27
27 28
M Makefile +5 -0
@@ -3,6 +3,11 @@
3 3
4 # Get version from git tag or use dev4 # Get version from git tag or use dev
5 VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")5 VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
6+# Append branch name if not on main
7+CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null)
8+ifeq ($(CURRENT_BRANCH),dev)
9+ VERSION := $(VERSION)-dev
10+endif
6 LDFLAGS := -ldflags "-X main.version=$(VERSION)"11 LDFLAGS := -ldflags "-X main.version=$(VERSION)"
7 LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w"12 LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w"
8 13
M cmd/archup-installer/main.go +16 -8
@@ -8,6 +8,7 @@ import (
8 tea "github.com/charmbracelet/bubbletea"8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/bnema/archup/internal/cleanup"9 "github.com/bnema/archup/internal/cleanup"
10 "github.com/bnema/archup/internal/config"10 "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/interfaces"
11 "github.com/bnema/archup/internal/logger"12 "github.com/bnema/archup/internal/logger"
12 "github.com/bnema/archup/internal/phases"13 "github.com/bnema/archup/internal/phases"
13 "github.com/bnema/archup/internal/ui"14 "github.com/bnema/archup/internal/ui"
@@ -61,16 +62,23 @@ func main() {
61 // Initialize orchestrator62 // Initialize orchestrator
62 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)63 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)
63 64
65+ // Create concrete implementations of interfaces for dependency injection
66+ fs := &interfaces.DefaultFileSystem{}
67+ httpClient := &interfaces.DefaultHTTPClient{}
68+ sysExec := &interfaces.DefaultSystemExecutor{}
69+ chrExec := &interfaces.DefaultChrootExecutor{}
70+ cmdExec := &interfaces.DefaultCommandExecutor{}
71+
64 // Register all phases with logger72 // Register all phases with logger
65 phasesToRegister := []phases.Phase{73 phasesToRegister := []phases.Phase{
66- phases.NewBootstrapPhase(cfg, log),74+ phases.NewBootstrapPhase(cfg, log, httpClient, fs),
67- phases.NewPreflightPhase(cfg, log),75+ phases.NewPreflightPhase(cfg, log, fs, cmdExec),
68- phases.NewPartitioningPhase(cfg, log),76+ phases.NewPartitioningPhase(cfg, log, sysExec),
69- phases.NewBaseInstallPhase(cfg, log),77+ phases.NewBaseInstallPhase(cfg, log, fs, sysExec),
70- phases.NewConfigPhase(cfg, log),78+ phases.NewConfigPhase(cfg, log, fs, sysExec, chrExec),
71- phases.NewBootPhase(cfg, log),79+ phases.NewBootPhase(cfg, log, fs, sysExec, chrExec),
72- phases.NewReposPhase(cfg, log),80+ phases.NewReposPhase(cfg, log, fs, sysExec, chrExec),
73- phases.NewPostInstallPhase(cfg, log),81+ phases.NewPostInstallPhase(cfg, log, fs, httpClient, sysExec, chrExec),
74 }82 }
75 83
76 for _, phase := range phasesToRegister {84 for _, phase := range phasesToRegister {
M go.mod +1 -0
@@ -7,6 +7,7 @@ require (
7 github.com/charmbracelet/bubbletea v1.3.107 github.com/charmbracelet/bubbletea v1.3.10
8 github.com/charmbracelet/huh v0.8.08 github.com/charmbracelet/huh v0.8.0
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10+ go.uber.org/mock v0.6.0
10 )11 )
11 12
12 require (13 require (
M go.sum +2 -0 generated file: collapsed
A internal/interfaces/interfaces.go +140 -0
@@ -0,0 +1,140 @@
1+package interfaces
2+
3+import (
4+ "io"
5+ "net/http"
6+ "os"
7+ "os/exec"
8+
9+ "github.com/bnema/archup/internal/system"
10+)
11+
12+// FileSystem abstracts file system operations for testing
13+type FileSystem interface {
14+ Stat(name string) (os.FileInfo, error)
15+ IsNotExist(err error) bool
16+ Open(name string) (io.ReadCloser, error)
17+ ReadFile(filename string) ([]byte, error)
18+ WriteFile(filename string, data []byte, perm os.FileMode) error
19+ Create(name string) (io.WriteCloser, error)
20+ MkdirAll(path string, perm os.FileMode) error
21+ RemoveAll(path string) error
22+ Chmod(name string, mode os.FileMode) error
23+}
24+
25+// CommandExecutor abstracts command execution
26+type CommandExecutor interface {
27+ Execute(name string, args ...string) ([]byte, error)
28+}
29+
30+// SystemExecutor abstracts system package functions
31+type SystemExecutor interface {
32+ RunSimple(command string, args ...string) system.CommandResult
33+ DetectCPUInfo() (*system.CPUInfo, error)
34+}
35+
36+// ChrootExecutor abstracts chroot operations
37+type ChrootExecutor interface {
38+ ChrootExec(logPath, mountPoint, command string, args ...string) error
39+ ChrootSystemctl(logPath, mountPoint, action, service string) error
40+ ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error
41+ ChrootPacman(logPath, mountPoint, operation string, packages ...string) error
42+ DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error
43+}
44+
45+// HTTPClient abstracts HTTP operations
46+type HTTPClient interface {
47+ Get(url string) (*http.Response, error)
48+}
49+
50+// ConfigSaver abstracts config save operation
51+type ConfigSaver interface {
52+ Save() error
53+}
54+
55+// DefaultFileSystem is the concrete implementation using OS functions
56+type DefaultFileSystem struct{}
57+
58+func (d *DefaultFileSystem) Stat(name string) (os.FileInfo, error) {
59+ return os.Stat(name)
60+}
61+
62+func (d *DefaultFileSystem) IsNotExist(err error) bool {
63+ return os.IsNotExist(err)
64+}
65+
66+func (d *DefaultFileSystem) Open(name string) (io.ReadCloser, error) {
67+ return os.Open(name)
68+}
69+
70+func (d *DefaultFileSystem) ReadFile(filename string) ([]byte, error) {
71+ return os.ReadFile(filename)
72+}
73+
74+func (d *DefaultFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error {
75+ return os.WriteFile(filename, data, perm)
76+}
77+
78+func (d *DefaultFileSystem) Create(name string) (io.WriteCloser, error) {
79+ return os.Create(name)
80+}
81+
82+func (d *DefaultFileSystem) MkdirAll(path string, perm os.FileMode) error {
83+ return os.MkdirAll(path, perm)
84+}
85+
86+func (d *DefaultFileSystem) RemoveAll(path string) error {
87+ return os.RemoveAll(path)
88+}
89+
90+func (d *DefaultFileSystem) Chmod(name string, mode os.FileMode) error {
91+ return os.Chmod(name, mode)
92+}
93+
94+// DefaultCommandExecutor is the concrete implementation using os/exec
95+type DefaultCommandExecutor struct{}
96+
97+func (d *DefaultCommandExecutor) Execute(name string, args ...string) ([]byte, error) {
98+ return exec.Command(name, args...).Output()
99+}
100+
101+// DefaultHTTPClient is the concrete implementation using net/http
102+type DefaultHTTPClient struct{}
103+
104+func (d *DefaultHTTPClient) Get(url string) (*http.Response, error) {
105+ return http.Get(url)
106+}
107+
108+// DefaultSystemExecutor is the concrete implementation using system functions
109+type DefaultSystemExecutor struct{}
110+
111+func (d *DefaultSystemExecutor) RunSimple(command string, args ...string) system.CommandResult {
112+ return system.RunSimple(command, args...)
113+}
114+
115+func (d *DefaultSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) {
116+ return system.DetectCPUInfo()
117+}
118+
119+// DefaultChrootExecutor is the concrete implementation using chroot functions
120+type DefaultChrootExecutor struct{}
121+
122+func (d *DefaultChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
123+ return system.ChrootExec(logPath, mountPoint, command, args...)
124+}
125+
126+func (d *DefaultChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
127+ return system.ChrootSystemctl(logPath, mountPoint, action, service)
128+}
129+
130+func (d *DefaultChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
131+ return system.ChrootExecWithStdin(logPath, mountPoint, command, stdin)
132+}
133+
134+func (d *DefaultChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
135+ return system.ChrootPacman(logPath, mountPoint, operation, packages...)
136+}
137+
138+func (d *DefaultChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
139+ return system.DownloadAndInstallPackages(logPath, chrootPath, urls...)
140+}
A internal/interfaces/mocks/mock_interfaces.go +462 -0
@@ -0,0 +1,462 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: /home/brice/dev/projects/archup/internal/interfaces/interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=/home/brice/dev/projects/archup/internal/interfaces/interfaces.go -destination=/home/brice/dev/projects/archup/internal/interfaces/mocks/mock_interfaces.go -package=mocks
7+//
8+
9+// Package mocks is a generated GoMock package.
10+package mocks
11+
12+import (
13+ io "io"
14+ http "net/http"
15+ os "os"
16+ reflect "reflect"
17+
18+ system "github.com/bnema/archup/internal/system"
19+ gomock "go.uber.org/mock/gomock"
20+)
21+
22+// MockFileSystem is a mock of FileSystem interface.
23+type MockFileSystem struct {
24+ ctrl *gomock.Controller
25+ recorder *MockFileSystemMockRecorder
26+ isgomock struct{}
27+}
28+
29+// MockFileSystemMockRecorder is the mock recorder for MockFileSystem.
30+type MockFileSystemMockRecorder struct {
31+ mock *MockFileSystem
32+}
33+
34+// NewMockFileSystem creates a new mock instance.
35+func NewMockFileSystem(ctrl *gomock.Controller) *MockFileSystem {
36+ mock := &MockFileSystem{ctrl: ctrl}
37+ mock.recorder = &MockFileSystemMockRecorder{mock}
38+ return mock
39+}
40+
41+// EXPECT returns an object that allows the caller to indicate expected use.
42+func (m *MockFileSystem) EXPECT() *MockFileSystemMockRecorder {
43+ return m.recorder
44+}
45+
46+// Chmod mocks base method.
47+func (m *MockFileSystem) Chmod(name string, mode os.FileMode) error {
48+ m.ctrl.T.Helper()
49+ ret := m.ctrl.Call(m, "Chmod", name, mode)
50+ ret0, _ := ret[0].(error)
51+ return ret0
52+}
53+
54+// Chmod indicates an expected call of Chmod.
55+func (mr *MockFileSystemMockRecorder) Chmod(name, mode any) *gomock.Call {
56+ mr.mock.ctrl.T.Helper()
57+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Chmod", reflect.TypeOf((*MockFileSystem)(nil).Chmod), name, mode)
58+}
59+
60+// Create mocks base method.
61+func (m *MockFileSystem) Create(name string) (io.WriteCloser, error) {
62+ m.ctrl.T.Helper()
63+ ret := m.ctrl.Call(m, "Create", name)
64+ ret0, _ := ret[0].(io.WriteCloser)
65+ ret1, _ := ret[1].(error)
66+ return ret0, ret1
67+}
68+
69+// Create indicates an expected call of Create.
70+func (mr *MockFileSystemMockRecorder) Create(name any) *gomock.Call {
71+ mr.mock.ctrl.T.Helper()
72+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFileSystem)(nil).Create), name)
73+}
74+
75+// IsNotExist mocks base method.
76+func (m *MockFileSystem) IsNotExist(err error) bool {
77+ m.ctrl.T.Helper()
78+ ret := m.ctrl.Call(m, "IsNotExist", err)
79+ ret0, _ := ret[0].(bool)
80+ return ret0
81+}
82+
83+// IsNotExist indicates an expected call of IsNotExist.
84+func (mr *MockFileSystemMockRecorder) IsNotExist(err any) *gomock.Call {
85+ mr.mock.ctrl.T.Helper()
86+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotExist", reflect.TypeOf((*MockFileSystem)(nil).IsNotExist), err)
87+}
88+
89+// MkdirAll mocks base method.
90+func (m *MockFileSystem) MkdirAll(path string, perm os.FileMode) error {
91+ m.ctrl.T.Helper()
92+ ret := m.ctrl.Call(m, "MkdirAll", path, perm)
93+ ret0, _ := ret[0].(error)
94+ return ret0
95+}
96+
97+// MkdirAll indicates an expected call of MkdirAll.
98+func (mr *MockFileSystemMockRecorder) MkdirAll(path, perm any) *gomock.Call {
99+ mr.mock.ctrl.T.Helper()
100+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockFileSystem)(nil).MkdirAll), path, perm)
101+}
102+
103+// Open mocks base method.
104+func (m *MockFileSystem) Open(name string) (io.ReadCloser, error) {
105+ m.ctrl.T.Helper()
106+ ret := m.ctrl.Call(m, "Open", name)
107+ ret0, _ := ret[0].(io.ReadCloser)
108+ ret1, _ := ret[1].(error)
109+ return ret0, ret1
110+}
111+
112+// Open indicates an expected call of Open.
113+func (mr *MockFileSystemMockRecorder) Open(name any) *gomock.Call {
114+ mr.mock.ctrl.T.Helper()
115+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFileSystem)(nil).Open), name)
116+}
117+
118+// ReadFile mocks base method.
119+func (m *MockFileSystem) ReadFile(filename string) ([]byte, error) {
120+ m.ctrl.T.Helper()
121+ ret := m.ctrl.Call(m, "ReadFile", filename)
122+ ret0, _ := ret[0].([]byte)
123+ ret1, _ := ret[1].(error)
124+ return ret0, ret1
125+}
126+
127+// ReadFile indicates an expected call of ReadFile.
128+func (mr *MockFileSystemMockRecorder) ReadFile(filename any) *gomock.Call {
129+ mr.mock.ctrl.T.Helper()
130+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileSystem)(nil).ReadFile), filename)
131+}
132+
133+// RemoveAll mocks base method.
134+func (m *MockFileSystem) RemoveAll(path string) error {
135+ m.ctrl.T.Helper()
136+ ret := m.ctrl.Call(m, "RemoveAll", path)
137+ ret0, _ := ret[0].(error)
138+ return ret0
139+}
140+
141+// RemoveAll indicates an expected call of RemoveAll.
142+func (mr *MockFileSystemMockRecorder) RemoveAll(path any) *gomock.Call {
143+ mr.mock.ctrl.T.Helper()
144+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAll", reflect.TypeOf((*MockFileSystem)(nil).RemoveAll), path)
145+}
146+
147+// Stat mocks base method.
148+func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) {
149+ m.ctrl.T.Helper()
150+ ret := m.ctrl.Call(m, "Stat", name)
151+ ret0, _ := ret[0].(os.FileInfo)
152+ ret1, _ := ret[1].(error)
153+ return ret0, ret1
154+}
155+
156+// Stat indicates an expected call of Stat.
157+func (mr *MockFileSystemMockRecorder) Stat(name any) *gomock.Call {
158+ mr.mock.ctrl.T.Helper()
159+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockFileSystem)(nil).Stat), name)
160+}
161+
162+// WriteFile mocks base method.
163+func (m *MockFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error {
164+ m.ctrl.T.Helper()
165+ ret := m.ctrl.Call(m, "WriteFile", filename, data, perm)
166+ ret0, _ := ret[0].(error)
167+ return ret0
168+}
169+
170+// WriteFile indicates an expected call of WriteFile.
171+func (mr *MockFileSystemMockRecorder) WriteFile(filename, data, perm any) *gomock.Call {
172+ mr.mock.ctrl.T.Helper()
173+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockFileSystem)(nil).WriteFile), filename, data, perm)
174+}
175+
176+// MockCommandExecutor is a mock of CommandExecutor interface.
177+type MockCommandExecutor struct {
178+ ctrl *gomock.Controller
179+ recorder *MockCommandExecutorMockRecorder
180+ isgomock struct{}
181+}
182+
183+// MockCommandExecutorMockRecorder is the mock recorder for MockCommandExecutor.
184+type MockCommandExecutorMockRecorder struct {
185+ mock *MockCommandExecutor
186+}
187+
188+// NewMockCommandExecutor creates a new mock instance.
189+func NewMockCommandExecutor(ctrl *gomock.Controller) *MockCommandExecutor {
190+ mock := &MockCommandExecutor{ctrl: ctrl}
191+ mock.recorder = &MockCommandExecutorMockRecorder{mock}
192+ return mock
193+}
194+
195+// EXPECT returns an object that allows the caller to indicate expected use.
196+func (m *MockCommandExecutor) EXPECT() *MockCommandExecutorMockRecorder {
197+ return m.recorder
198+}
199+
200+// Execute mocks base method.
201+func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) {
202+ m.ctrl.T.Helper()
203+ varargs := []any{name}
204+ for _, a := range args {
205+ varargs = append(varargs, a)
206+ }
207+ ret := m.ctrl.Call(m, "Execute", varargs...)
208+ ret0, _ := ret[0].([]byte)
209+ ret1, _ := ret[1].(error)
210+ return ret0, ret1
211+}
212+
213+// Execute indicates an expected call of Execute.
214+func (mr *MockCommandExecutorMockRecorder) Execute(name any, args ...any) *gomock.Call {
215+ mr.mock.ctrl.T.Helper()
216+ varargs := append([]any{name}, args...)
217+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)
218+}
219+
220+// MockSystemExecutor is a mock of SystemExecutor interface.
221+type MockSystemExecutor struct {
222+ ctrl *gomock.Controller
223+ recorder *MockSystemExecutorMockRecorder
224+ isgomock struct{}
225+}
226+
227+// MockSystemExecutorMockRecorder is the mock recorder for MockSystemExecutor.
228+type MockSystemExecutorMockRecorder struct {
229+ mock *MockSystemExecutor
230+}
231+
232+// NewMockSystemExecutor creates a new mock instance.
233+func NewMockSystemExecutor(ctrl *gomock.Controller) *MockSystemExecutor {
234+ mock := &MockSystemExecutor{ctrl: ctrl}
235+ mock.recorder = &MockSystemExecutorMockRecorder{mock}
236+ return mock
237+}
238+
239+// EXPECT returns an object that allows the caller to indicate expected use.
240+func (m *MockSystemExecutor) EXPECT() *MockSystemExecutorMockRecorder {
241+ return m.recorder
242+}
243+
244+// DetectCPUInfo mocks base method.
245+func (m *MockSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) {
246+ m.ctrl.T.Helper()
247+ ret := m.ctrl.Call(m, "DetectCPUInfo")
248+ ret0, _ := ret[0].(*system.CPUInfo)
249+ ret1, _ := ret[1].(error)
250+ return ret0, ret1
251+}
252+
253+// DetectCPUInfo indicates an expected call of DetectCPUInfo.
254+func (mr *MockSystemExecutorMockRecorder) DetectCPUInfo() *gomock.Call {
255+ mr.mock.ctrl.T.Helper()
256+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectCPUInfo", reflect.TypeOf((*MockSystemExecutor)(nil).DetectCPUInfo))
257+}
258+
259+// RunSimple mocks base method.
260+func (m *MockSystemExecutor) RunSimple(command string, args ...string) system.CommandResult {
261+ m.ctrl.T.Helper()
262+ varargs := []any{command}
263+ for _, a := range args {
264+ varargs = append(varargs, a)
265+ }
266+ ret := m.ctrl.Call(m, "RunSimple", varargs...)
267+ ret0, _ := ret[0].(system.CommandResult)
268+ return ret0
269+}
270+
271+// RunSimple indicates an expected call of RunSimple.
272+func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *gomock.Call {
273+ mr.mock.ctrl.T.Helper()
274+ varargs := append([]any{command}, args...)
275+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)
276+}
277+
278+// MockChrootExecutor is a mock of ChrootExecutor interface.
279+type MockChrootExecutor struct {
280+ ctrl *gomock.Controller
281+ recorder *MockChrootExecutorMockRecorder
282+ isgomock struct{}
283+}
284+
285+// MockChrootExecutorMockRecorder is the mock recorder for MockChrootExecutor.
286+type MockChrootExecutorMockRecorder struct {
287+ mock *MockChrootExecutor
288+}
289+
290+// NewMockChrootExecutor creates a new mock instance.
291+func NewMockChrootExecutor(ctrl *gomock.Controller) *MockChrootExecutor {
292+ mock := &MockChrootExecutor{ctrl: ctrl}
293+ mock.recorder = &MockChrootExecutorMockRecorder{mock}
294+ return mock
295+}
296+
297+// EXPECT returns an object that allows the caller to indicate expected use.
298+func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
299+ return m.recorder
300+}
301+
302+// ChrootExec mocks base method.
303+func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
304+ m.ctrl.T.Helper()
305+ varargs := []any{logPath, mountPoint, command}
306+ for _, a := range args {
307+ varargs = append(varargs, a)
308+ }
309+ ret := m.ctrl.Call(m, "ChrootExec", varargs...)
310+ ret0, _ := ret[0].(error)
311+ return ret0
312+}
313+
314+// ChrootExec indicates an expected call of ChrootExec.
315+func (mr *MockChrootExecutorMockRecorder) ChrootExec(logPath, mountPoint, command any, args ...any) *gomock.Call {
316+ mr.mock.ctrl.T.Helper()
317+ varargs := append([]any{logPath, mountPoint, command}, args...)
318+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExec", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExec), varargs...)
319+}
320+
321+// ChrootExecWithStdin mocks base method.
322+func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
323+ m.ctrl.T.Helper()
324+ ret := m.ctrl.Call(m, "ChrootExecWithStdin", logPath, mountPoint, command, stdin)
325+ ret0, _ := ret[0].(error)
326+ return ret0
327+}
328+
329+// ChrootExecWithStdin indicates an expected call of ChrootExecWithStdin.
330+func (mr *MockChrootExecutorMockRecorder) ChrootExecWithStdin(logPath, mountPoint, command, stdin any) *gomock.Call {
331+ mr.mock.ctrl.T.Helper()
332+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithStdin", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithStdin), logPath, mountPoint, command, stdin)
333+}
334+
335+// ChrootPacman mocks base method.
336+func (m *MockChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
337+ m.ctrl.T.Helper()
338+ varargs := []any{logPath, mountPoint, operation}
339+ for _, a := range packages {
340+ varargs = append(varargs, a)
341+ }
342+ ret := m.ctrl.Call(m, "ChrootPacman", varargs...)
343+ ret0, _ := ret[0].(error)
344+ return ret0
345+}
346+
347+// ChrootPacman indicates an expected call of ChrootPacman.
348+func (mr *MockChrootExecutorMockRecorder) ChrootPacman(logPath, mountPoint, operation any, packages ...any) *gomock.Call {
349+ mr.mock.ctrl.T.Helper()
350+ varargs := append([]any{logPath, mountPoint, operation}, packages...)
351+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootPacman", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootPacman), varargs...)
352+}
353+
354+// ChrootSystemctl mocks base method.
355+func (m *MockChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
356+ m.ctrl.T.Helper()
357+ ret := m.ctrl.Call(m, "ChrootSystemctl", logPath, mountPoint, action, service)
358+ ret0, _ := ret[0].(error)
359+ return ret0
360+}
361+
362+// ChrootSystemctl indicates an expected call of ChrootSystemctl.
363+func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(logPath, mountPoint, action, service any) *gomock.Call {
364+ mr.mock.ctrl.T.Helper()
365+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), logPath, mountPoint, action, service)
366+}
367+
368+// DownloadAndInstallPackages mocks base method.
369+func (m *MockChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
370+ m.ctrl.T.Helper()
371+ varargs := []any{logPath, chrootPath}
372+ for _, a := range urls {
373+ varargs = append(varargs, a)
374+ }
375+ ret := m.ctrl.Call(m, "DownloadAndInstallPackages", varargs...)
376+ ret0, _ := ret[0].(error)
377+ return ret0
378+}
379+
380+// DownloadAndInstallPackages indicates an expected call of DownloadAndInstallPackages.
381+func (mr *MockChrootExecutorMockRecorder) DownloadAndInstallPackages(logPath, chrootPath any, urls ...any) *gomock.Call {
382+ mr.mock.ctrl.T.Helper()
383+ varargs := append([]any{logPath, chrootPath}, urls...)
384+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndInstallPackages", reflect.TypeOf((*MockChrootExecutor)(nil).DownloadAndInstallPackages), varargs...)
385+}
386+
387+// MockHTTPClient is a mock of HTTPClient interface.
388+type MockHTTPClient struct {
389+ ctrl *gomock.Controller
390+ recorder *MockHTTPClientMockRecorder
391+ isgomock struct{}
392+}
393+
394+// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient.
395+type MockHTTPClientMockRecorder struct {
396+ mock *MockHTTPClient
397+}
398+
399+// NewMockHTTPClient creates a new mock instance.
400+func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient {
401+ mock := &MockHTTPClient{ctrl: ctrl}
402+ mock.recorder = &MockHTTPClientMockRecorder{mock}
403+ return mock
404+}
405+
406+// EXPECT returns an object that allows the caller to indicate expected use.
407+func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder {
408+ return m.recorder
409+}
410+
411+// Get mocks base method.
412+func (m *MockHTTPClient) Get(url string) (*http.Response, error) {
413+ m.ctrl.T.Helper()
414+ ret := m.ctrl.Call(m, "Get", url)
415+ ret0, _ := ret[0].(*http.Response)
416+ ret1, _ := ret[1].(error)
417+ return ret0, ret1
418+}
419+
420+// Get indicates an expected call of Get.
421+func (mr *MockHTTPClientMockRecorder) Get(url any) *gomock.Call {
422+ mr.mock.ctrl.T.Helper()
423+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), url)
424+}
425+
426+// MockConfigSaver is a mock of ConfigSaver interface.
427+type MockConfigSaver struct {
428+ ctrl *gomock.Controller
429+ recorder *MockConfigSaverMockRecorder
430+ isgomock struct{}
431+}
432+
433+// MockConfigSaverMockRecorder is the mock recorder for MockConfigSaver.
434+type MockConfigSaverMockRecorder struct {
435+ mock *MockConfigSaver
436+}
437+
438+// NewMockConfigSaver creates a new mock instance.
439+func NewMockConfigSaver(ctrl *gomock.Controller) *MockConfigSaver {
440+ mock := &MockConfigSaver{ctrl: ctrl}
441+ mock.recorder = &MockConfigSaverMockRecorder{mock}
442+ return mock
443+}
444+
445+// EXPECT returns an object that allows the caller to indicate expected use.
446+func (m *MockConfigSaver) EXPECT() *MockConfigSaverMockRecorder {
447+ return m.recorder
448+}
449+
450+// Save mocks base method.
451+func (m *MockConfigSaver) Save() error {
452+ m.ctrl.T.Helper()
453+ ret := m.ctrl.Call(m, "Save")
454+ ret0, _ := ret[0].(error)
455+ return ret0
456+}
457+
458+// Save indicates an expected call of Save.
459+func (mr *MockConfigSaverMockRecorder) Save() *gomock.Call {
460+ mr.mock.ctrl.T.Helper()
461+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigSaver)(nil).Save))
462+}
M internal/phases/base.go +12 -9
@@ -3,31 +3,34 @@ package phases
3 import (3 import (
4 "bufio"4 "bufio"
5 "fmt"5 "fmt"
6- "os"
7 "strings"6 "strings"
8 7
9 "github.com/bnema/archup/internal/config"8 "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces"
10 "github.com/bnema/archup/internal/logger"10 "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
12 )11 )
13 12
14 // BaseInstallPhase handles base system installation13 // BaseInstallPhase handles base system installation
15 type BaseInstallPhase struct {14 type BaseInstallPhase struct {
16 *BasePhase15 *BasePhase
16+ fs interfaces.FileSystem
17+ sysExec interfaces.SystemExecutor
17 packages []string18 packages []string
18 }19 }
19 20
20 // NewBaseInstallPhase creates a new base installation phase21 // NewBaseInstallPhase creates a new base installation phase
21-func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger) *BaseInstallPhase {22+func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor) *BaseInstallPhase {
22 return &BaseInstallPhase{23 return &BaseInstallPhase{
23 BasePhase: NewBasePhase("base", "Base System Installation", cfg, log),24 BasePhase: NewBasePhase("base", "Base System Installation", cfg, log),
25+ fs: fs,
26+ sysExec: sysExec,
24 }27 }
25 }28 }
26 29
27 // PreCheck validates prerequisites30 // PreCheck validates prerequisites
28 func (p *BaseInstallPhase) PreCheck() error {31 func (p *BaseInstallPhase) PreCheck() error {
29 // Verify /mnt is mounted32 // Verify /mnt is mounted
30- result := system.RunSimple("mountpoint", "-q", "/mnt")33+ result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
31 switch {34 switch {
32 case result.ExitCode != 0:35 case result.ExitCode != 0:
33 return fmt.Errorf("/mnt is not mounted")36 return fmt.Errorf("/mnt is not mounted")
@@ -103,7 +106,7 @@ func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
103 p.SendOutput(progressChan, "Detecting CPU vendor...")106 p.SendOutput(progressChan, "Detecting CPU vendor...")
104 107
105 // Read /proc/cpuinfo to detect vendor108 // Read /proc/cpuinfo to detect vendor
106- file, err := os.Open("/proc/cpuinfo")109+ file, err := p.fs.Open("/proc/cpuinfo")
107 switch {110 switch {
108 case err != nil:111 case err != nil:
109 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)112 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
@@ -208,7 +211,7 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
208 // Use DefaultInstallDir directly to match where bootstrap downloads files211 // Use DefaultInstallDir directly to match where bootstrap downloads files
209 packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile212 packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
210 213
211- file, err := os.Open(packageFile)214+ file, err := p.fs.Open(packageFile)
212 switch {215 switch {
213 case err != nil:216 case err != nil:
214 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)217 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
@@ -244,7 +247,7 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
244 func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error {247 func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error {
245 p.SendOutput(progressChan, "Generating fstab with UUIDs...")248 p.SendOutput(progressChan, "Generating fstab with UUIDs...")
246 249
247- result := system.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")250+ result := p.sysExec.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")
248 switch {251 switch {
249 case result.ExitCode != 0:252 case result.ExitCode != 0:
250 return fmt.Errorf("genfstab failed: %w", result.Error)253 return fmt.Errorf("genfstab failed: %w", result.Error)
@@ -257,14 +260,14 @@ func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) err
257 // PostCheck validates installation260 // PostCheck validates installation
258 func (p *BaseInstallPhase) PostCheck() error {261 func (p *BaseInstallPhase) PostCheck() error {
259 // Check if base system exists262 // Check if base system exists
260- result := system.RunSimple("test", "-d", "/mnt/usr")263+ result := p.sysExec.RunSimple("test", "-d", "/mnt/usr")
261 switch {264 switch {
262 case result.ExitCode != 0:265 case result.ExitCode != 0:
263 return fmt.Errorf("/mnt/usr does not exist")266 return fmt.Errorf("/mnt/usr does not exist")
264 }267 }
265 268
266 // Check if fstab exists269 // Check if fstab exists
267- result = system.RunSimple("test", "-f", "/mnt/etc/fstab")270+ result = p.sysExec.RunSimple("test", "-f", "/mnt/etc/fstab")
268 switch {271 switch {
269 case result.ExitCode != 0:272 case result.ExitCode != 0:
270 return fmt.Errorf("/mnt/etc/fstab was not created")273 return fmt.Errorf("/mnt/etc/fstab was not created")
M internal/phases/base_test.go +3 -13
@@ -62,7 +62,7 @@ func TestBaseInstallPhasePreCheck(t *testing.T) {
62 return62 return
63 }63 }
64 if tt.wantErr && err != nil {64 if tt.wantErr && err != nil {
65- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {65+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
66 t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)66 t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
67 }67 }
68 }68 }
@@ -284,7 +284,7 @@ func TestBaseInstallPhaseGenerateFstab(t *testing.T) {
284 return284 return
285 }285 }
286 if tt.wantErr && err != nil {286 if tt.wantErr && err != nil {
287- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {287+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
288 t.Errorf("generateFstab() error = %q, should contain %q", errMsg, tt.errContains)288 t.Errorf("generateFstab() error = %q, should contain %q", errMsg, tt.errContains)
289 }289 }
290 }290 }
@@ -366,7 +366,7 @@ func TestBaseInstallPhasePostCheck(t *testing.T) {
366 return366 return
367 }367 }
368 if tt.wantErr && err != nil {368 if tt.wantErr && err != nil {
369- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {369+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
370 t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)370 t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
371 }371 }
372 }372 }
@@ -457,13 +457,3 @@ func TestBaseInstallPhaseCanSkip(t *testing.T) {
457 t.Error("CanSkip() = true, want false")457 t.Error("CanSkip() = true, want false")
458 }458 }
459 }459 }
460-
461-// Helper function for substring matching (avoid duplication across test files)
462-func containsSubstringBaseTest(s, substr string) bool {
463- for i := 0; i < len(s)-len(substr)+1; i++ {
464- if s[i:i+len(substr)] == substr {
465- return true
466- }
467- }
468- return false
469-}
M internal/phases/boot.go +25 -19
@@ -2,12 +2,12 @@ package phases
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6 "path/filepath"5 "path/filepath"
7 "regexp"6 "regexp"
8 "strings"7 "strings"
9 8
10 "github.com/bnema/archup/internal/config"9 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
11 "github.com/bnema/archup/internal/logger"11 "github.com/bnema/archup/internal/logger"
12 "github.com/bnema/archup/internal/system"12 "github.com/bnema/archup/internal/system"
13 )13 )
@@ -15,26 +15,32 @@ import (
15 // BootPhase handles bootloader installation15 // BootPhase handles bootloader installation
16 type BootPhase struct {16 type BootPhase struct {
17 *BasePhase17 *BasePhase
18+ fs interfaces.FileSystem
19+ sysExec interfaces.SystemExecutor
20+ chrExec interfaces.ChrootExecutor
18 }21 }
19 22
20 // NewBootPhase creates a new boot phase23 // NewBootPhase creates a new boot phase
21-func NewBootPhase(cfg *config.Config, log *logger.Logger) *BootPhase {24+func NewBootPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *BootPhase {
22 return &BootPhase{25 return &BootPhase{
23 BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log),26 BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log),
27+ fs: fs,
28+ sysExec: sysExec,
29+ chrExec: chrExec,
24 }30 }
25 }31 }
26 32
27 // PreCheck validates boot prerequisites33 // PreCheck validates boot prerequisites
28 func (p *BootPhase) PreCheck() error {34 func (p *BootPhase) PreCheck() error {
29 // Verify /mnt is mounted35 // Verify /mnt is mounted
30- result := system.RunSimple("mountpoint", "-q", config.PathMnt)36+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
31 switch {37 switch {
32 case result.ExitCode != 0:38 case result.ExitCode != 0:
33 return fmt.Errorf("%s is not mounted", config.PathMnt)39 return fmt.Errorf("%s is not mounted", config.PathMnt)
34 }40 }
35 41
36 // Verify boot partition is mounted42 // Verify boot partition is mounted
37- result = system.RunSimple("mountpoint", "-q", config.PathMntBoot)43+ result = p.sysExec.RunSimple("mountpoint", "-q", config.PathMntBoot)
38 switch {44 switch {
39 case result.ExitCode != 0:45 case result.ExitCode != 0:
40 return fmt.Errorf("%s is not mounted", config.PathMntBoot)46 return fmt.Errorf("%s is not mounted", config.PathMntBoot)
@@ -92,7 +98,7 @@ func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) erro
92 p.SendOutput(progressChan, "Configuring initramfs...")98 p.SendOutput(progressChan, "Configuring initramfs...")
93 99
94 // Read current mkinitcpio.conf100 // Read current mkinitcpio.conf
95- content, err := os.ReadFile(config.PathMntEtcMkinitcpio)101+ content, err := p.fs.ReadFile(config.PathMntEtcMkinitcpio)
96 switch {102 switch {
97 case err != nil:103 case err != nil:
98 return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)104 return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
@@ -114,14 +120,14 @@ func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) erro
114 newContent := hooksRegex.ReplaceAllString(string(content), hooks)120 newContent := hooksRegex.ReplaceAllString(string(content), hooks)
115 121
116 // Write updated config122 // Write updated config
117- switch err := os.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); {123+ switch err := p.fs.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); {
118 case err != nil:124 case err != nil:
119 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)125 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
120 }126 }
121 127
122 // Regenerate initramfs128 // Regenerate initramfs
123 p.SendOutput(progressChan, "Regenerating initramfs...")129 p.SendOutput(progressChan, "Regenerating initramfs...")
124- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {130+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
125 case err != nil:131 case err != nil:
126 return fmt.Errorf("failed to regenerate initramfs: %w", err)132 return fmt.Errorf("failed to regenerate initramfs: %w", err)
127 }133 }
@@ -137,7 +143,7 @@ func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
137 143
138 // Install Limine to disk (BIOS - optional, may fail on UEFI-only)144 // Install Limine to disk (BIOS - optional, may fail on UEFI-only)
139 biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk)145 biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk)
140- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, biosCmd); {146+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, biosCmd); {
141 case err != nil:147 case err != nil:
142 p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)")148 p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)")
143 default:149 default:
@@ -145,7 +151,7 @@ func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
145 }151 }
146 152
147 // Create Limine directory153 // Create Limine directory
148- switch err := os.MkdirAll(config.PathMntBootEFILimine, 0755); {154+ switch err := p.fs.MkdirAll(config.PathMntBootEFILimine, 0755); {
149 case err != nil:155 case err != nil:
150 return fmt.Errorf("failed to create Limine directory: %w", err)156 return fmt.Errorf("failed to create Limine directory: %w", err)
151 }157 }
@@ -172,7 +178,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
172 178
173 // Get root partition UUID179 // Get root partition UUID
174 rootPartition := p.config.RootPartition180 rootPartition := p.config.RootPartition
175- result := system.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)181+ result := p.sysExec.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)
176 switch {182 switch {
177 case result.ExitCode != 0:183 case result.ExitCode != 0:
178 return fmt.Errorf("failed to get root partition UUID: %w", result.Error)184 return fmt.Errorf("failed to get root partition UUID: %w", result.Error)
@@ -196,7 +202,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
196 switch {202 switch {
197 case p.config.AMDPState != "":203 case p.config.AMDPState != "":
198 // Detect CPU to get Zen generation for proper kernel params204 // Detect CPU to get Zen generation for proper kernel params
199- cpuInfo, err := system.DetectCPUInfo()205+ cpuInfo, err := p.sysExec.DetectCPUInfo()
200 var amdParams string206 var amdParams string
201 if err == nil && cpuInfo.AMDZenGen != nil {207 if err == nil && cpuInfo.AMDZenGen != nil {
202 // Use helper function that adds extra params for Zen 2 if needed208 // Use helper function that adds extra params for Zen 2 if needed
@@ -217,7 +223,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
217 223
218 // Read Limine config template224 // Read Limine config template
219 templatePath := p.getInstallPath(config.LimineConfigTemplate)225 templatePath := p.getInstallPath(config.LimineConfigTemplate)
220- templateContent, err := os.ReadFile(templatePath)226+ templateContent, err := p.fs.ReadFile(templatePath)
221 switch {227 switch {
222 case err != nil:228 case err != nil:
223 return fmt.Errorf("failed to read Limine template: %w", err)229 return fmt.Errorf("failed to read Limine template: %w", err)
@@ -234,7 +240,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
234 240
235 // Write Limine configuration241 // Write Limine configuration
236 limineConfigPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)242 limineConfigPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
237- switch err := os.WriteFile(limineConfigPath, []byte(limineConfig), 0644); {243+ switch err := p.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); {
238 case err != nil:244 case err != nil:
239 return fmt.Errorf("failed to write Limine config: %w", err)245 return fmt.Errorf("failed to write Limine config: %w", err)
240 }246 }
@@ -263,7 +269,7 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
263 config.UEFIBootLoader,269 config.UEFIBootLoader,
264 )270 )
265 271
266- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, efiCmd); {272+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, efiCmd); {
267 case err != nil:273 case err != nil:
268 return fmt.Errorf("failed to create UEFI boot entry: %w", err)274 return fmt.Errorf("failed to create UEFI boot entry: %w", err)
269 }275 }
@@ -293,15 +299,15 @@ func (p *BootPhase) extractPartitionNumber(partPath string) string {
293 func (p *BootPhase) PostCheck() error {299 func (p *BootPhase) PostCheck() error {
294 // Verify Limine bootloader exists300 // Verify Limine bootloader exists
295 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)301 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
296- switch _, err := os.Stat(bootloaderPath); {302+ switch _, err := p.fs.Stat(bootloaderPath); {
297- case os.IsNotExist(err):303+ case p.fs.IsNotExist(err):
298 return fmt.Errorf("Limine bootloader was not installed")304 return fmt.Errorf("Limine bootloader was not installed")
299 }305 }
300 306
301 // Verify Limine config exists307 // Verify Limine config exists
302 configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)308 configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
303- switch _, err := os.Stat(configPath); {309+ switch _, err := p.fs.Stat(configPath); {
304- case os.IsNotExist(err):310+ case p.fs.IsNotExist(err):
305 return fmt.Errorf("Limine config was not created")311 return fmt.Errorf("Limine config was not created")
306 }312 }
307 313
@@ -311,7 +317,7 @@ func (p *BootPhase) PostCheck() error {
311 // Rollback for boot phase317 // Rollback for boot phase
312 func (p *BootPhase) Rollback() error {318 func (p *BootPhase) Rollback() error {
313 // Remove Limine files if they exist319 // Remove Limine files if they exist
314- switch err := os.RemoveAll(config.PathMntBootEFILimine); {320+ switch err := p.fs.RemoveAll(config.PathMntBootEFILimine); {
315 case err != nil:321 case err != nil:
316 p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err))322 p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err))
317 }323 }
A internal/phases/boot_test.go +526 -0
@@ -0,0 +1,526 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
10+ "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
13+)
14+
15+// TestBootPhasePreCheck tests boot prerequisites
16+func TestBootPhasePreCheck(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ setupMocks func(*mocks.MockSystemExecutor)
20+ wantErr bool
21+ errContains string
22+ }{
23+ {
24+ name: "Both /mnt and /mnt/boot mounted",
25+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
26+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 0}).Times(1)
28+ },
29+ wantErr: false,
30+ },
31+ {
32+ name: "/mnt not mounted",
33+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
34+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
35+ },
36+ wantErr: true,
37+ errContains: "is not mounted",
38+ },
39+ {
40+ name: "/mnt/boot not mounted",
41+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
42+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
43+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 32}).Times(1)
44+ },
45+ wantErr: true,
46+ errContains: "is not mounted",
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ ctrl := gomock.NewController(t)
53+ defer ctrl.Finish()
54+
55+ mockFS := mocks.NewMockFileSystem(ctrl)
56+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
57+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
58+
59+ tt.setupMocks(mockSysExec)
60+
61+ tmpDir := t.TempDir()
62+ logPath := filepath.Join(tmpDir, "test.log")
63+ log, err := logger.New(logPath, false)
64+ if err != nil {
65+ t.Fatalf("Failed to create logger: %v", err)
66+ }
67+ defer log.Close()
68+
69+ cfg := config.NewConfig("test")
70+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
71+
72+ err = phase.PreCheck()
73+ if (err != nil) != tt.wantErr {
74+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
75+ return
76+ }
77+ if tt.wantErr && err != nil {
78+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
79+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
80+ }
81+ }
82+ })
83+ }
84+}
85+
86+// TestBootPhaseExtractPartitionNumber tests partition number extraction
87+func TestBootPhaseExtractPartitionNumber(t *testing.T) {
88+ tests := []struct {
89+ name string
90+ devicePath string
91+ expectedNum string
92+ }{
93+ {
94+ name: "SATA partition /dev/sda1",
95+ devicePath: "/dev/sda1",
96+ expectedNum: "1",
97+ },
98+ {
99+ name: "SATA partition /dev/sdb2",
100+ devicePath: "/dev/sdb2",
101+ expectedNum: "2",
102+ },
103+ {
104+ name: "NVMe partition /dev/nvme0n1p1",
105+ devicePath: "/dev/nvme0n1p1",
106+ expectedNum: "1",
107+ },
108+ {
109+ name: "NVMe partition /dev/nvme0n1p2",
110+ devicePath: "/dev/nvme0n1p2",
111+ expectedNum: "2",
112+ },
113+ {
114+ name: "VirtIO partition /dev/vda1",
115+ devicePath: "/dev/vda1",
116+ expectedNum: "1",
117+ },
118+ {
119+ name: "Multiple digit partition /dev/sda12",
120+ devicePath: "/dev/sda12",
121+ expectedNum: "12",
122+ },
123+ {
124+ name: "No partition number - returns empty",
125+ devicePath: "/dev/sda",
126+ expectedNum: "",
127+ },
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ ctrl := gomock.NewController(t)
133+ defer ctrl.Finish()
134+
135+ mockFS := mocks.NewMockFileSystem(ctrl)
136+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
137+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
138+
139+ tmpDir := t.TempDir()
140+ logPath := filepath.Join(tmpDir, "test.log")
141+ log, err := logger.New(logPath, false)
142+ if err != nil {
143+ t.Fatalf("Failed to create logger: %v", err)
144+ }
145+ defer log.Close()
146+
147+ cfg := config.NewConfig("test")
148+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
149+
150+ result := phase.extractPartitionNumber(tt.devicePath)
151+ if result != tt.expectedNum {
152+ t.Errorf("extractPartitionNumber(%q) = %q, want %q", tt.devicePath, result, tt.expectedNum)
153+ }
154+ })
155+ }
156+}
157+
158+// TestBootPhasePostCheck tests boot configuration validation
159+func TestBootPhasePostCheck(t *testing.T) {
160+ tests := []struct {
161+ name string
162+ setupMocks func(*mocks.MockFileSystem)
163+ wantErr bool
164+ errContains string
165+ }{
166+ {
167+ name: "Both bootloader and config exist",
168+ setupMocks: func(mockFS *mocks.MockFileSystem) {
169+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
170+ configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
171+
172+ mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
173+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
174+ mockFS.EXPECT().Stat(configPath).Return(mockFileInfo{}, nil).Times(1)
175+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
176+ },
177+ wantErr: false,
178+ },
179+ {
180+ name: "Bootloader missing",
181+ setupMocks: func(mockFS *mocks.MockFileSystem) {
182+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
183+
184+ mockFS.EXPECT().Stat(bootloaderPath).Return(nil, fmt.Errorf("not found")).Times(1)
185+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
186+ },
187+ wantErr: true,
188+ errContains: "bootloader was not installed",
189+ },
190+ {
191+ name: "Config missing",
192+ setupMocks: func(mockFS *mocks.MockFileSystem) {
193+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
194+ configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
195+
196+ mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
197+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
198+ mockFS.EXPECT().Stat(configPath).Return(nil, fmt.Errorf("not found")).Times(1)
199+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
200+ },
201+ wantErr: true,
202+ errContains: "config was not created",
203+ },
204+ }
205+
206+ for _, tt := range tests {
207+ t.Run(tt.name, func(t *testing.T) {
208+ ctrl := gomock.NewController(t)
209+ defer ctrl.Finish()
210+
211+ mockFS := mocks.NewMockFileSystem(ctrl)
212+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
213+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
214+
215+ tt.setupMocks(mockFS)
216+
217+ tmpDir := t.TempDir()
218+ logPath := filepath.Join(tmpDir, "test.log")
219+ log, err := logger.New(logPath, false)
220+ if err != nil {
221+ t.Fatalf("Failed to create logger: %v", err)
222+ }
223+ defer log.Close()
224+
225+ cfg := config.NewConfig("test")
226+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
227+
228+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
229+
230+ err = phase.PostCheck()
231+ if (err != nil) != tt.wantErr {
232+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
233+ return
234+ }
235+ if tt.wantErr && err != nil {
236+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
237+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
238+ }
239+ }
240+ })
241+ }
242+}
243+
244+// TestBootPhaseRollback tests boot phase rollback
245+func TestBootPhaseRollback(t *testing.T) {
246+ tests := []struct {
247+ name string
248+ setupMocks func(*mocks.MockFileSystem)
249+ wantErr bool
250+ }{
251+ {
252+ name: "Rollback success",
253+ setupMocks: func(mockFS *mocks.MockFileSystem) {
254+ mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(nil).Times(1)
255+ },
256+ wantErr: false,
257+ },
258+ {
259+ name: "Rollback with directory removal failure - continues",
260+ setupMocks: func(mockFS *mocks.MockFileSystem) {
261+ mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(fmt.Errorf("permission denied")).Times(1)
262+ },
263+ wantErr: false, // Rollback doesn't fail on removal errors
264+ },
265+ }
266+
267+ for _, tt := range tests {
268+ t.Run(tt.name, func(t *testing.T) {
269+ ctrl := gomock.NewController(t)
270+ defer ctrl.Finish()
271+
272+ mockFS := mocks.NewMockFileSystem(ctrl)
273+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
274+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
275+
276+ tt.setupMocks(mockFS)
277+
278+ tmpDir := t.TempDir()
279+ logPath := filepath.Join(tmpDir, "test.log")
280+ log, err := logger.New(logPath, false)
281+ if err != nil {
282+ t.Fatalf("Failed to create logger: %v", err)
283+ }
284+ defer log.Close()
285+
286+ cfg := config.NewConfig("test")
287+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
288+
289+ err = phase.Rollback()
290+ if (err != nil) != tt.wantErr {
291+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
292+ }
293+ })
294+ }
295+}
296+
297+// TestBootPhaseCanSkip tests the CanSkip method
298+func TestBootPhaseCanSkip(t *testing.T) {
299+ ctrl := gomock.NewController(t)
300+ defer ctrl.Finish()
301+
302+ mockFS := mocks.NewMockFileSystem(ctrl)
303+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
304+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
305+
306+ tmpDir := t.TempDir()
307+ logPath := filepath.Join(tmpDir, "test.log")
308+ log, err := logger.New(logPath, false)
309+ if err != nil {
310+ t.Fatalf("Failed to create logger: %v", err)
311+ }
312+ defer log.Close()
313+
314+ cfg := config.NewConfig("test")
315+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
316+
317+ if phase.CanSkip() {
318+ t.Error("CanSkip() = true, want false")
319+ }
320+}
321+
322+// TestBootPhaseExecute tests the Execute method structure
323+func TestBootPhaseExecute(t *testing.T) {
324+ ctrl := gomock.NewController(t)
325+ defer ctrl.Finish()
326+
327+ mockFS := mocks.NewMockFileSystem(ctrl)
328+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
329+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
330+
331+ // Allow any file system operations since Execute calls various methods
332+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
333+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
334+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
335+ mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
336+ mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any(), gomock.Any()).Return(system.CommandResult{Output: "1234-5678"}).AnyTimes()
337+
338+ tmpDir := t.TempDir()
339+ logPath := filepath.Join(tmpDir, "test.log")
340+ log, err := logger.New(logPath, false)
341+ if err != nil {
342+ t.Fatalf("Failed to create logger: %v", err)
343+ }
344+ defer log.Close()
345+
346+ cfg := config.NewConfig("test")
347+ cfg.TargetDisk = "/dev/sda"
348+ cfg.EFIPartition = "/dev/sda1"
349+ cfg.RootPartition = "/dev/sda2"
350+ cfg.KernelChoice = "linux"
351+
352+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
353+
354+ progressChan := make(chan ProgressUpdate, 100)
355+
356+ // Note: Full Execute testing is complex due to logger.ExecCommand() and various file system calls
357+ // We just verify that Execute returns a PhaseResult
358+ result := phase.Execute(progressChan)
359+
360+ // Verify result type is correct - either success or error, the structure is valid
361+ _ = result // If we got here, Execute returned a valid result structure
362+}
363+
364+// TestBootPhaseKernelParameterConstruction tests kernel parameter building
365+func TestBootPhaseKernelParameterConstruction(t *testing.T) {
366+ tests := []struct {
367+ name string
368+ encryptionType string
369+ rootUUID string
370+ expectedParams string // partial match check
371+ }{
372+ {
373+ name: "Unencrypted root",
374+ encryptionType: config.EncryptionNone,
375+ rootUUID: "1234-5678",
376+ expectedParams: "root=UUID=1234-5678",
377+ },
378+ {
379+ name: "LUKS encrypted root",
380+ encryptionType: config.EncryptionLUKS,
381+ rootUUID: "1234-5678",
382+ expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
383+ },
384+ {
385+ name: "LUKS+LVM encrypted root",
386+ encryptionType: config.EncryptionLUKSLVM,
387+ rootUUID: "1234-5678",
388+ expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
389+ },
390+ }
391+
392+ for _, tt := range tests {
393+ t.Run(tt.name, func(t *testing.T) {
394+ ctrl := gomock.NewController(t)
395+ defer ctrl.Finish()
396+
397+ mockFS := mocks.NewMockFileSystem(ctrl)
398+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
399+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
400+
401+ tmpDir := t.TempDir()
402+ logPath := filepath.Join(tmpDir, "test.log")
403+ log, err := logger.New(logPath, false)
404+ if err != nil {
405+ t.Fatalf("Failed to create logger: %v", err)
406+ }
407+ defer log.Close()
408+
409+ cfg := config.NewConfig("test")
410+ cfg.EncryptionType = tt.encryptionType
411+ cfg.RootPartition = "/dev/sda2"
412+
413+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
414+
415+ // Verify encryption type is stored correctly
416+ if phase.config.EncryptionType != tt.encryptionType {
417+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
418+ }
419+ })
420+ }
421+}
422+
423+// TestBootPhaseInstallPathConstruction tests install path building
424+func TestBootPhaseInstallPathConstruction(t *testing.T) {
425+ tests := []struct {
426+ name string
427+ filename string
428+ expected string
429+ }{
430+ {
431+ name: "Limine config template",
432+ filename: config.LimineConfigTemplate,
433+ expected: filepath.Join(config.DefaultInstallDir, config.LimineConfigTemplate),
434+ },
435+ {
436+ name: "Custom filename",
437+ filename: "test.conf",
438+ expected: filepath.Join(config.DefaultInstallDir, "test.conf"),
439+ },
440+ }
441+
442+ for _, tt := range tests {
443+ t.Run(tt.name, func(t *testing.T) {
444+ ctrl := gomock.NewController(t)
445+ defer ctrl.Finish()
446+
447+ mockFS := mocks.NewMockFileSystem(ctrl)
448+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
449+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
450+
451+ tmpDir := t.TempDir()
452+ logPath := filepath.Join(tmpDir, "test.log")
453+ log, err := logger.New(logPath, false)
454+ if err != nil {
455+ t.Fatalf("Failed to create logger: %v", err)
456+ }
457+ defer log.Close()
458+
459+ cfg := config.NewConfig("test")
460+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
461+
462+ result := phase.getInstallPath(tt.filename)
463+ if result != tt.expected {
464+ t.Errorf("getInstallPath(%q) = %q, want %q", tt.filename, result, tt.expected)
465+ }
466+ })
467+ }
468+}
469+
470+// TestBootPhaseFileSystemOperations tests file system interaction patterns
471+func TestBootPhaseFileSystemOperations(t *testing.T) {
472+ tests := []struct {
473+ name string
474+ operation string // "read", "write", "mkdir"
475+ }{
476+ {
477+ name: "Mkinitcpio config read",
478+ operation: "read",
479+ },
480+ {
481+ name: "Limine config write",
482+ operation: "write",
483+ },
484+ {
485+ name: "Create Limine directory",
486+ operation: "mkdir",
487+ },
488+ }
489+
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ ctrl := gomock.NewController(t)
493+ defer ctrl.Finish()
494+
495+ mockFS := mocks.NewMockFileSystem(ctrl)
496+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
497+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
498+
499+ // Setup basic expectations based on operation
500+ switch tt.operation {
501+ case "read":
502+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
503+ case "write":
504+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
505+ case "mkdir":
506+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
507+ }
508+
509+ tmpDir := t.TempDir()
510+ logPath := filepath.Join(tmpDir, "test.log")
511+ log, err := logger.New(logPath, false)
512+ if err != nil {
513+ t.Fatalf("Failed to create logger: %v", err)
514+ }
515+ defer log.Close()
516+
517+ cfg := config.NewConfig("test")
518+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
519+
520+ // Verify phase can be created
521+ if phase == nil {
522+ t.Error("BootPhase is nil")
523+ }
524+ })
525+ }
526+}
M internal/phases/bootstrap.go +13 -9
@@ -4,29 +4,33 @@ import (
4 "fmt"4 "fmt"
5 "io"5 "io"
6 "net/http"6 "net/http"
7- "os"
8 "path/filepath"7 "path/filepath"
9 8
10 "github.com/bnema/archup/internal/config"9 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
11 "github.com/bnema/archup/internal/logger"11 "github.com/bnema/archup/internal/logger"
12 )12 )
13 13
14 // BootstrapPhase handles initial setup and file downloads14 // BootstrapPhase handles initial setup and file downloads
15 type BootstrapPhase struct {15 type BootstrapPhase struct {
16 *BasePhase16 *BasePhase
17+ httpClient interfaces.HTTPClient
18+ fs interfaces.FileSystem
17 }19 }
18 20
19 // NewBootstrapPhase creates a new bootstrap phase21 // NewBootstrapPhase creates a new bootstrap phase
20-func NewBootstrapPhase(cfg *config.Config, log *logger.Logger) *BootstrapPhase {22+func NewBootstrapPhase(cfg *config.Config, log *logger.Logger, httpClient interfaces.HTTPClient, fs interfaces.FileSystem) *BootstrapPhase {
21 return &BootstrapPhase{23 return &BootstrapPhase{
22- BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),24+ BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),
25+ httpClient: httpClient,
26+ fs: fs,
23 }27 }
24 }28 }
25 29
26 // PreCheck validates bootstrap prerequisites30 // PreCheck validates bootstrap prerequisites
27 func (p *BootstrapPhase) PreCheck() error {31 func (p *BootstrapPhase) PreCheck() error {
28 // Check internet connectivity32 // Check internet connectivity
29- resp, err := http.Get("https://raw.githubusercontent.com")33+ resp, err := p.httpClient.Get("https://raw.githubusercontent.com")
30 switch {34 switch {
31 case err != nil:35 case err != nil:
32 return fmt.Errorf("no internet connectivity: %w", err)36 return fmt.Errorf("no internet connectivity: %w", err)
@@ -41,7 +45,7 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
41 p.SendOutput(progressChan, "Starting bootstrap phase...")45 p.SendOutput(progressChan, "Starting bootstrap phase...")
42 46
43 // Create install directory47 // Create install directory
44- switch err := os.MkdirAll(config.DefaultInstallDir, 0755); {48+ switch err := p.fs.MkdirAll(config.DefaultInstallDir, 0755); {
45 case err != nil:49 case err != nil:
46 p.SendError(progressChan, err)50 p.SendError(progressChan, err)
47 return PhaseResult{Success: false, Error: err}51 return PhaseResult{Success: false, Error: err}
@@ -126,13 +130,13 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
126 func (p *BootstrapPhase) downloadFile(url, destPath string) error {130 func (p *BootstrapPhase) downloadFile(url, destPath string) error {
127 // Create destination directory if needed131 // Create destination directory if needed
128 destDir := filepath.Dir(destPath)132 destDir := filepath.Dir(destPath)
129- switch err := os.MkdirAll(destDir, 0755); {133+ switch err := p.fs.MkdirAll(destDir, 0755); {
130 case err != nil:134 case err != nil:
131 return fmt.Errorf("failed to create directory: %w", err)135 return fmt.Errorf("failed to create directory: %w", err)
132 }136 }
133 137
134 // Download file138 // Download file
135- resp, err := http.Get(url)139+ resp, err := p.httpClient.Get(url)
136 switch {140 switch {
137 case err != nil:141 case err != nil:
138 return fmt.Errorf("HTTP request failed: %w", err)142 return fmt.Errorf("HTTP request failed: %w", err)
@@ -145,7 +149,7 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
145 }149 }
146 150
147 // Create destination file151 // Create destination file
148- destFile, err := os.Create(destPath)152+ destFile, err := p.fs.Create(destPath)
149 switch {153 switch {
150 case err != nil:154 case err != nil:
151 return fmt.Errorf("failed to create file: %w", err)155 return fmt.Errorf("failed to create file: %w", err)
@@ -164,7 +168,7 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
164 // Rollback removes downloaded files168 // Rollback removes downloaded files
165 func (p *BootstrapPhase) Rollback() error {169 func (p *BootstrapPhase) Rollback() error {
166 // Remove install directory170 // Remove install directory
167- switch err := os.RemoveAll(config.DefaultInstallDir); {171+ switch err := p.fs.RemoveAll(config.DefaultInstallDir); {
168 case err != nil:172 case err != nil:
169 return fmt.Errorf("failed to remove install directory: %w", err)173 return fmt.Errorf("failed to remove install directory: %w", err)
170 }174 }
M internal/phases/bootstrap_test.go +1 -9
@@ -346,12 +346,4 @@ func (m *MockWriteCloser) Close() error {
346 return nil346 return nil
347 }347 }
348 348
349-// containsSubstring helper function (also defined in preflight_test.go)349+// containsSubstring is defined in helpers_test.go to avoid duplication
350-func containsSubstring(s, substr string) bool {
351- for i := 0; i < len(s)-len(substr)+1; i++ {
352- if s[i:i+len(substr)] == substr {
353- return true
354- }
355- }
356- return false
357-}
A internal/phases/mocks/mock_phase.go +381 -0
@@ -0,0 +1,381 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: internal/phases/phase.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=internal/phases/phase.go -destination=internal/phases/mocks/mock_phase.go -package=mocks -typed -self_package=github.com/bnema/archup/internal/phases
7+//
8+
9+// Package mocks is a generated GoMock package.
10+package mocks
11+
12+import (
13+ reflect "reflect"
14+
15+ phases "github.com/bnema/archup/internal/phases"
16+ gomock "go.uber.org/mock/gomock"
17+)
18+
19+// MockPhase is a mock of Phase interface.
20+type MockPhase struct {
21+ ctrl *gomock.Controller
22+ recorder *MockPhaseMockRecorder
23+ isgomock struct{}
24+}
25+
26+// MockPhaseMockRecorder is the mock recorder for MockPhase.
27+type MockPhaseMockRecorder struct {
28+ mock *MockPhase
29+}
30+
31+// NewMockPhase creates a new mock instance.
32+func NewMockPhase(ctrl *gomock.Controller) *MockPhase {
33+ mock := &MockPhase{ctrl: ctrl}
34+ mock.recorder = &MockPhaseMockRecorder{mock}
35+ return mock
36+}
37+
38+// EXPECT returns an object that allows the caller to indicate expected use.
39+func (m *MockPhase) EXPECT() *MockPhaseMockRecorder {
40+ return m.recorder
41+}
42+
43+// CanSkip mocks base method.
44+func (m *MockPhase) CanSkip() bool {
45+ m.ctrl.T.Helper()
46+ ret := m.ctrl.Call(m, "CanSkip")
47+ ret0, _ := ret[0].(bool)
48+ return ret0
49+}
50+
51+// CanSkip indicates an expected call of CanSkip.
52+func (mr *MockPhaseMockRecorder) CanSkip() *MockPhaseCanSkipCall {
53+ mr.mock.ctrl.T.Helper()
54+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSkip", reflect.TypeOf((*MockPhase)(nil).CanSkip))
55+ return &MockPhaseCanSkipCall{Call: call}
56+}
57+
58+// MockPhaseCanSkipCall wrap *gomock.Call
59+type MockPhaseCanSkipCall struct {
60+ *gomock.Call
61+}
62+
63+// Return rewrite *gomock.Call.Return
64+func (c *MockPhaseCanSkipCall) Return(arg0 bool) *MockPhaseCanSkipCall {
65+ c.Call = c.Call.Return(arg0)
66+ return c
67+}
68+
69+// Do rewrite *gomock.Call.Do
70+func (c *MockPhaseCanSkipCall) Do(f func() bool) *MockPhaseCanSkipCall {
71+ c.Call = c.Call.Do(f)
72+ return c
73+}
74+
75+// DoAndReturn rewrite *gomock.Call.DoAndReturn
76+func (c *MockPhaseCanSkipCall) DoAndReturn(f func() bool) *MockPhaseCanSkipCall {
77+ c.Call = c.Call.DoAndReturn(f)
78+ return c
79+}
80+
81+// Description mocks base method.
82+func (m *MockPhase) Description() string {
83+ m.ctrl.T.Helper()
84+ ret := m.ctrl.Call(m, "Description")
85+ ret0, _ := ret[0].(string)
86+ return ret0
87+}
88+
89+// Description indicates an expected call of Description.
90+func (mr *MockPhaseMockRecorder) Description() *MockPhaseDescriptionCall {
91+ mr.mock.ctrl.T.Helper()
92+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Description", reflect.TypeOf((*MockPhase)(nil).Description))
93+ return &MockPhaseDescriptionCall{Call: call}
94+}
95+
96+// MockPhaseDescriptionCall wrap *gomock.Call
97+type MockPhaseDescriptionCall struct {
98+ *gomock.Call
99+}
100+
101+// Return rewrite *gomock.Call.Return
102+func (c *MockPhaseDescriptionCall) Return(arg0 string) *MockPhaseDescriptionCall {
103+ c.Call = c.Call.Return(arg0)
104+ return c
105+}
106+
107+// Do rewrite *gomock.Call.Do
108+func (c *MockPhaseDescriptionCall) Do(f func() string) *MockPhaseDescriptionCall {
109+ c.Call = c.Call.Do(f)
110+ return c
111+}
112+
113+// DoAndReturn rewrite *gomock.Call.DoAndReturn
114+func (c *MockPhaseDescriptionCall) DoAndReturn(f func() string) *MockPhaseDescriptionCall {
115+ c.Call = c.Call.DoAndReturn(f)
116+ return c
117+}
118+
119+// Execute mocks base method.
120+func (m *MockPhase) Execute(progressChan chan<- phases.ProgressUpdate) phases.PhaseResult {
121+ m.ctrl.T.Helper()
122+ ret := m.ctrl.Call(m, "Execute", progressChan)
123+ ret0, _ := ret[0].(phases.PhaseResult)
124+ return ret0
125+}
126+
127+// Execute indicates an expected call of Execute.
128+func (mr *MockPhaseMockRecorder) Execute(progressChan any) *MockPhaseExecuteCall {
129+ mr.mock.ctrl.T.Helper()
130+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockPhase)(nil).Execute), progressChan)
131+ return &MockPhaseExecuteCall{Call: call}
132+}
133+
134+// MockPhaseExecuteCall wrap *gomock.Call
135+type MockPhaseExecuteCall struct {
136+ *gomock.Call
137+}
138+
139+// Return rewrite *gomock.Call.Return
140+func (c *MockPhaseExecuteCall) Return(arg0 phases.PhaseResult) *MockPhaseExecuteCall {
141+ c.Call = c.Call.Return(arg0)
142+ return c
143+}
144+
145+// Do rewrite *gomock.Call.Do
146+func (c *MockPhaseExecuteCall) Do(f func(chan<- phases.ProgressUpdate) phases.PhaseResult) *MockPhaseExecuteCall {
147+ c.Call = c.Call.Do(f)
148+ return c
149+}
150+
151+// DoAndReturn rewrite *gomock.Call.DoAndReturn
152+func (c *MockPhaseExecuteCall) DoAndReturn(f func(chan<- phases.ProgressUpdate) phases.PhaseResult) *MockPhaseExecuteCall {
153+ c.Call = c.Call.DoAndReturn(f)
154+ return c
155+}
156+
157+// Name mocks base method.
158+func (m *MockPhase) Name() string {
159+ m.ctrl.T.Helper()
160+ ret := m.ctrl.Call(m, "Name")
161+ ret0, _ := ret[0].(string)
162+ return ret0
163+}
164+
165+// Name indicates an expected call of Name.
166+func (mr *MockPhaseMockRecorder) Name() *MockPhaseNameCall {
167+ mr.mock.ctrl.T.Helper()
168+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockPhase)(nil).Name))
169+ return &MockPhaseNameCall{Call: call}
170+}
171+
172+// MockPhaseNameCall wrap *gomock.Call
173+type MockPhaseNameCall struct {
174+ *gomock.Call
175+}
176+
177+// Return rewrite *gomock.Call.Return
178+func (c *MockPhaseNameCall) Return(arg0 string) *MockPhaseNameCall {
179+ c.Call = c.Call.Return(arg0)
180+ return c
181+}
182+
183+// Do rewrite *gomock.Call.Do
184+func (c *MockPhaseNameCall) Do(f func() string) *MockPhaseNameCall {
185+ c.Call = c.Call.Do(f)
186+ return c
187+}
188+
189+// DoAndReturn rewrite *gomock.Call.DoAndReturn
190+func (c *MockPhaseNameCall) DoAndReturn(f func() string) *MockPhaseNameCall {
191+ c.Call = c.Call.DoAndReturn(f)
192+ return c
193+}
194+
195+// PostCheck mocks base method.
196+func (m *MockPhase) PostCheck() error {
197+ m.ctrl.T.Helper()
198+ ret := m.ctrl.Call(m, "PostCheck")
199+ ret0, _ := ret[0].(error)
200+ return ret0
201+}
202+
203+// PostCheck indicates an expected call of PostCheck.
204+func (mr *MockPhaseMockRecorder) PostCheck() *MockPhasePostCheckCall {
205+ mr.mock.ctrl.T.Helper()
206+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostCheck", reflect.TypeOf((*MockPhase)(nil).PostCheck))
207+ return &MockPhasePostCheckCall{Call: call}
208+}
209+
210+// MockPhasePostCheckCall wrap *gomock.Call
211+type MockPhasePostCheckCall struct {
212+ *gomock.Call
213+}
214+
215+// Return rewrite *gomock.Call.Return
216+func (c *MockPhasePostCheckCall) Return(arg0 error) *MockPhasePostCheckCall {
217+ c.Call = c.Call.Return(arg0)
218+ return c
219+}
220+
221+// Do rewrite *gomock.Call.Do
222+func (c *MockPhasePostCheckCall) Do(f func() error) *MockPhasePostCheckCall {
223+ c.Call = c.Call.Do(f)
224+ return c
225+}
226+
227+// DoAndReturn rewrite *gomock.Call.DoAndReturn
228+func (c *MockPhasePostCheckCall) DoAndReturn(f func() error) *MockPhasePostCheckCall {
229+ c.Call = c.Call.DoAndReturn(f)
230+ return c
231+}
232+
233+// PreCheck mocks base method.
234+func (m *MockPhase) PreCheck() error {
235+ m.ctrl.T.Helper()
236+ ret := m.ctrl.Call(m, "PreCheck")
237+ ret0, _ := ret[0].(error)
238+ return ret0
239+}
240+
241+// PreCheck indicates an expected call of PreCheck.
242+func (mr *MockPhaseMockRecorder) PreCheck() *MockPhasePreCheckCall {
243+ mr.mock.ctrl.T.Helper()
244+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreCheck", reflect.TypeOf((*MockPhase)(nil).PreCheck))
245+ return &MockPhasePreCheckCall{Call: call}
246+}
247+
248+// MockPhasePreCheckCall wrap *gomock.Call
249+type MockPhasePreCheckCall struct {
250+ *gomock.Call
251+}
252+
253+// Return rewrite *gomock.Call.Return
254+func (c *MockPhasePreCheckCall) Return(arg0 error) *MockPhasePreCheckCall {
255+ c.Call = c.Call.Return(arg0)
256+ return c
257+}
258+
259+// Do rewrite *gomock.Call.Do
260+func (c *MockPhasePreCheckCall) Do(f func() error) *MockPhasePreCheckCall {
261+ c.Call = c.Call.Do(f)
262+ return c
263+}
264+
265+// DoAndReturn rewrite *gomock.Call.DoAndReturn
266+func (c *MockPhasePreCheckCall) DoAndReturn(f func() error) *MockPhasePreCheckCall {
267+ c.Call = c.Call.DoAndReturn(f)
268+ return c
269+}
270+
271+// Rollback mocks base method.
272+func (m *MockPhase) Rollback() error {
273+ m.ctrl.T.Helper()
274+ ret := m.ctrl.Call(m, "Rollback")
275+ ret0, _ := ret[0].(error)
276+ return ret0
277+}
278+
279+// Rollback indicates an expected call of Rollback.
280+func (mr *MockPhaseMockRecorder) Rollback() *MockPhaseRollbackCall {
281+ mr.mock.ctrl.T.Helper()
282+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rollback", reflect.TypeOf((*MockPhase)(nil).Rollback))
283+ return &MockPhaseRollbackCall{Call: call}
284+}
285+
286+// MockPhaseRollbackCall wrap *gomock.Call
287+type MockPhaseRollbackCall struct {
288+ *gomock.Call
289+}
290+
291+// Return rewrite *gomock.Call.Return
292+func (c *MockPhaseRollbackCall) Return(arg0 error) *MockPhaseRollbackCall {
293+ c.Call = c.Call.Return(arg0)
294+ return c
295+}
296+
297+// Do rewrite *gomock.Call.Do
298+func (c *MockPhaseRollbackCall) Do(f func() error) *MockPhaseRollbackCall {
299+ c.Call = c.Call.Do(f)
300+ return c
301+}
302+
303+// DoAndReturn rewrite *gomock.Call.DoAndReturn
304+func (c *MockPhaseRollbackCall) DoAndReturn(f func() error) *MockPhaseRollbackCall {
305+ c.Call = c.Call.DoAndReturn(f)
306+ return c
307+}
308+
309+// SetStatus mocks base method.
310+func (m *MockPhase) SetStatus(status phases.PhaseStatus) {
311+ m.ctrl.T.Helper()
312+ m.ctrl.Call(m, "SetStatus", status)
313+}
314+
315+// SetStatus indicates an expected call of SetStatus.
316+func (mr *MockPhaseMockRecorder) SetStatus(status any) *MockPhaseSetStatusCall {
317+ mr.mock.ctrl.T.Helper()
318+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockPhase)(nil).SetStatus), status)
319+ return &MockPhaseSetStatusCall{Call: call}
320+}
321+
322+// MockPhaseSetStatusCall wrap *gomock.Call
323+type MockPhaseSetStatusCall struct {
324+ *gomock.Call
325+}
326+
327+// Return rewrite *gomock.Call.Return
328+func (c *MockPhaseSetStatusCall) Return() *MockPhaseSetStatusCall {
329+ c.Call = c.Call.Return()
330+ return c
331+}
332+
333+// Do rewrite *gomock.Call.Do
334+func (c *MockPhaseSetStatusCall) Do(f func(phases.PhaseStatus)) *MockPhaseSetStatusCall {
335+ c.Call = c.Call.Do(f)
336+ return c
337+}
338+
339+// DoAndReturn rewrite *gomock.Call.DoAndReturn
340+func (c *MockPhaseSetStatusCall) DoAndReturn(f func(phases.PhaseStatus)) *MockPhaseSetStatusCall {
341+ c.Call = c.Call.DoAndReturn(f)
342+ return c
343+}
344+
345+// Status mocks base method.
346+func (m *MockPhase) Status() phases.PhaseStatus {
347+ m.ctrl.T.Helper()
348+ ret := m.ctrl.Call(m, "Status")
349+ ret0, _ := ret[0].(phases.PhaseStatus)
350+ return ret0
351+}
352+
353+// Status indicates an expected call of Status.
354+func (mr *MockPhaseMockRecorder) Status() *Mockphases.PhaseStatusCall {
355+ mr.mock.ctrl.T.Helper()
356+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockPhase)(nil).Status))
357+ return &Mockphases.PhaseStatusCall{Call: call}
358+}
359+
360+// Mockphases.PhaseStatusCall wrap *gomock.Call
361+type Mockphases.PhaseStatusCall struct {
362+ *gomock.Call
363+}
364+
365+// Return rewrite *gomock.Call.Return
366+func (c *Mockphases.PhaseStatusCall) Return(arg0 phases.PhaseStatus) *Mockphases.PhaseStatusCall {
367+ c.Call = c.Call.Return(arg0)
368+ return c
369+}
370+
371+// Do rewrite *gomock.Call.Do
372+func (c *Mockphases.PhaseStatusCall) Do(f func() phases.PhaseStatus) *Mockphases.PhaseStatusCall {
373+ c.Call = c.Call.Do(f)
374+ return c
375+}
376+
377+// DoAndReturn rewrite *gomock.Call.DoAndReturn
378+func (c *Mockphases.PhaseStatusCall) DoAndReturn(f func() phases.PhaseStatus) *Mockphases.PhaseStatusCall {
379+ c.Call = c.Call.DoAndReturn(f)
380+ return c
381+}
A internal/phases/orchestrator_test.go +545 -0
@@ -0,0 +1,545 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/archup/internal/config"
8+)
9+
10+// testPhase is a simple Phase implementation for testing orchestrator
11+type testPhase struct {
12+ *BasePhase
13+ preCheckErr error
14+ executeErr error
15+ postCheckErr error
16+ rollbackErr error
17+ canSkip bool
18+}
19+
20+func newTestPhase(name string, cfg *config.Config) *testPhase {
21+ return &testPhase{
22+ BasePhase: NewBasePhase(name, "Test "+name, cfg, nil),
23+ canSkip: false,
24+ }
25+}
26+
27+func (p *testPhase) PreCheck() error {
28+ return p.preCheckErr
29+}
30+
31+func (p *testPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
32+ if p.executeErr != nil {
33+ return PhaseResult{
34+ Success: false,
35+ Error: p.executeErr,
36+ }
37+ }
38+ return PhaseResult{
39+ Success: true,
40+ Message: "completed",
41+ }
42+}
43+
44+func (p *testPhase) PostCheck() error {
45+ return p.postCheckErr
46+}
47+
48+func (p *testPhase) Rollback() error {
49+ return p.rollbackErr
50+}
51+
52+func (p *testPhase) CanSkip() bool {
53+ return p.canSkip
54+}
55+
56+// TestOrchestratorRegistration tests phase registration
57+func TestOrchestratorRegistration(t *testing.T) {
58+ cfg := config.NewConfig("test")
59+ orch := NewOrchestrator(cfg, "test.log")
60+
61+ t.Run("register valid phase", func(t *testing.T) {
62+ phase := newTestPhase("test-phase", cfg)
63+ err := orch.RegisterPhase(phase)
64+ if err != nil {
65+ t.Errorf("Failed to register valid phase: %v", err)
66+ }
67+
68+ phases := orch.Phases()
69+ if len(phases) != 1 {
70+ t.Errorf("Expected 1 phase, got %d", len(phases))
71+ }
72+ })
73+
74+ t.Run("register nil phase", func(t *testing.T) {
75+ orch2 := NewOrchestrator(cfg, "test.log")
76+ err := orch2.RegisterPhase(nil)
77+ if err == nil {
78+ t.Error("Expected error for nil phase, got nil")
79+ }
80+ })
81+
82+ t.Run("register duplicate phase name", func(t *testing.T) {
83+ orch2 := NewOrchestrator(cfg, "test.log")
84+ phase1 := newTestPhase("duplicate", cfg)
85+ phase2 := newTestPhase("duplicate", cfg)
86+
87+ orch2.RegisterPhase(phase1)
88+ err := orch2.RegisterPhase(phase2)
89+ if err == nil {
90+ t.Error("Expected error for duplicate phase name, got nil")
91+ }
92+ })
93+}
94+
95+// TestOrchestratorCurrentPhase tests CurrentPhase navigation
96+func TestOrchestratorCurrentPhase(t *testing.T) {
97+ cfg := config.NewConfig("test")
98+ orch := NewOrchestrator(cfg, "test.log")
99+
100+ phase := newTestPhase("test", cfg)
101+ orch.RegisterPhase(phase)
102+
103+ // Before execution, current should be nil
104+ if orch.CurrentPhase() != nil {
105+ t.Error("CurrentPhase should be nil before execution")
106+ }
107+
108+ // After executing, current should be set
109+ orch.ExecutePhase(phase)
110+ current := orch.CurrentPhase()
111+ if current == nil {
112+ t.Error("CurrentPhase should not be nil after execution")
113+ }
114+ if current.Name() != "test" {
115+ t.Errorf("CurrentPhase name = %q, want %q", current.Name(), "test")
116+ }
117+}
118+
119+// TestOrchestratorNextPhase tests NextPhase logic
120+func TestOrchestratorNextPhase(t *testing.T) {
121+ cfg := config.NewConfig("test")
122+ orch := NewOrchestrator(cfg, "test.log")
123+
124+ phase1 := newTestPhase("phase-1", cfg)
125+ phase2 := newTestPhase("phase-2", cfg)
126+ phase3 := newTestPhase("phase-3", cfg)
127+
128+ orch.RegisterPhase(phase1)
129+ orch.RegisterPhase(phase2)
130+ orch.RegisterPhase(phase3)
131+
132+ // Next should be phase-1
133+ next := orch.NextPhase()
134+ if next == nil || next.Name() != "phase-1" {
135+ t.Errorf("NextPhase = %v, want phase-1", next)
136+ }
137+
138+ // Mark phase-1 completed
139+ phase1.SetStatus(StatusCompleted)
140+
141+ // Next should be phase-2
142+ next = orch.NextPhase()
143+ if next == nil || next.Name() != "phase-2" {
144+ t.Errorf("NextPhase = %v, want phase-2", next)
145+ }
146+
147+ // Mark phase-2 skipped
148+ phase2.SetStatus(StatusSkipped)
149+
150+ // Next should be phase-3
151+ next = orch.NextPhase()
152+ if next == nil || next.Name() != "phase-3" {
153+ t.Errorf("NextPhase = %v, want phase-3", next)
154+ }
155+
156+ // Mark all done
157+ phase3.SetStatus(StatusCompleted)
158+
159+ // Next should be nil
160+ next = orch.NextPhase()
161+ if next != nil {
162+ t.Errorf("NextPhase = %v, want nil when all done", next)
163+ }
164+}
165+
166+// TestOrchestratorExecutePhaseSuccess tests successful phase execution
167+func TestOrchestratorExecutePhaseSuccess(t *testing.T) {
168+ cfg := config.NewConfig("test")
169+ orch := NewOrchestrator(cfg, "test.log")
170+
171+ phase := newTestPhase("test", cfg)
172+ orch.RegisterPhase(phase)
173+
174+ err := orch.ExecutePhase(phase)
175+ if err != nil {
176+ t.Errorf("ExecutePhase failed: %v", err)
177+ }
178+
179+ if phase.Status() != StatusCompleted {
180+ t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
181+ }
182+}
183+
184+// TestOrchestratorExecutePhasePreCheckFails tests PreCheck failure
185+func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) {
186+ cfg := config.NewConfig("test")
187+ orch := NewOrchestrator(cfg, "test.log")
188+
189+ phase := newTestPhase("test", cfg)
190+ phase.preCheckErr = fmt.Errorf("precondition failed")
191+ orch.RegisterPhase(phase)
192+
193+ err := orch.ExecutePhase(phase)
194+ if err == nil {
195+ t.Error("Expected error from failed PreCheck")
196+ }
197+
198+ if phase.Status() != StatusFailed {
199+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
200+ }
201+}
202+
203+// TestOrchestratorExecutePhaseExecuteFails tests Execute failure with Rollback
204+func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) {
205+ cfg := config.NewConfig("test")
206+ orch := NewOrchestrator(cfg, "test.log")
207+
208+ phase := newTestPhase("test", cfg)
209+ phase.executeErr = fmt.Errorf("execution failed")
210+ orch.RegisterPhase(phase)
211+
212+ err := orch.ExecutePhase(phase)
213+ if err == nil {
214+ t.Error("Expected error from failed Execute")
215+ }
216+
217+ if phase.Status() != StatusFailed {
218+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
219+ }
220+}
221+
222+// TestOrchestratorExecutePhaseRollbackFails tests Rollback failure
223+func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) {
224+ cfg := config.NewConfig("test")
225+ orch := NewOrchestrator(cfg, "test.log")
226+
227+ phase := newTestPhase("test", cfg)
228+ phase.executeErr = fmt.Errorf("execution failed")
229+ phase.rollbackErr = fmt.Errorf("rollback also failed")
230+ orch.RegisterPhase(phase)
231+
232+ err := orch.ExecutePhase(phase)
233+ if err == nil {
234+ t.Error("Expected error from failed Rollback")
235+ }
236+
237+ if phase.Status() != StatusFailed {
238+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
239+ }
240+}
241+
242+// TestOrchestratorExecutePhasePostCheckFails tests PostCheck failure
243+func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) {
244+ cfg := config.NewConfig("test")
245+ orch := NewOrchestrator(cfg, "test.log")
246+
247+ phase := newTestPhase("test", cfg)
248+ phase.postCheckErr = fmt.Errorf("validation failed")
249+ orch.RegisterPhase(phase)
250+
251+ err := orch.ExecutePhase(phase)
252+ if err == nil {
253+ t.Error("Expected error from failed PostCheck")
254+ }
255+
256+ if phase.Status() != StatusFailed {
257+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
258+ }
259+}
260+
261+// TestOrchestratorSkipPhase tests skipping phases
262+func TestOrchestratorSkipPhase(t *testing.T) {
263+ cfg := config.NewConfig("test")
264+ orch := NewOrchestrator(cfg, "test.log")
265+
266+ t.Run("skip skippable phase", func(t *testing.T) {
267+ phase := newTestPhase("skippable", cfg)
268+ phase.canSkip = true
269+ orch.RegisterPhase(phase)
270+
271+ err := orch.SkipPhase(phase)
272+ if err != nil {
273+ t.Errorf("SkipPhase failed: %v", err)
274+ }
275+
276+ if phase.Status() != StatusSkipped {
277+ t.Errorf("Status = %v, want %v", phase.Status(), StatusSkipped)
278+ }
279+ })
280+
281+ t.Run("skip non-skippable phase", func(t *testing.T) {
282+ orch2 := NewOrchestrator(cfg, "test.log")
283+ phase := newTestPhase("non-skippable", cfg)
284+ phase.canSkip = false
285+ orch2.RegisterPhase(phase)
286+
287+ err := orch2.SkipPhase(phase)
288+ if err == nil {
289+ t.Error("Expected error when skipping non-skippable phase")
290+ }
291+ })
292+}
293+
294+// TestOrchestratorProgress tests progress calculation
295+func TestOrchestratorProgress(t *testing.T) {
296+ cfg := config.NewConfig("test")
297+ orch := NewOrchestrator(cfg, "test.log")
298+
299+ phase1 := newTestPhase("phase-1", cfg)
300+ phase2 := newTestPhase("phase-2", cfg)
301+ phase3 := newTestPhase("phase-3", cfg)
302+
303+ orch.RegisterPhase(phase1)
304+ orch.RegisterPhase(phase2)
305+ orch.RegisterPhase(phase3)
306+
307+ // Initially all pending
308+ completed, total := orch.Progress()
309+ if total != 3 {
310+ t.Errorf("Total = %d, want 3", total)
311+ }
312+ if completed != 0 {
313+ t.Errorf("Completed = %d, want 0", completed)
314+ }
315+
316+ // Mark one completed, one skipped
317+ phase1.SetStatus(StatusCompleted)
318+ phase2.SetStatus(StatusSkipped)
319+
320+ completed, total = orch.Progress()
321+ if completed != 2 {
322+ t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)
323+ }
324+}
325+
326+// TestOrchestratorIsComplete tests completion detection
327+func TestOrchestratorIsComplete(t *testing.T) {
328+ cfg := config.NewConfig("test")
329+
330+ t.Run("all completed", func(t *testing.T) {
331+ orch := NewOrchestrator(cfg, "test.log")
332+ phase := newTestPhase("test", cfg)
333+ phase.SetStatus(StatusCompleted)
334+ orch.RegisterPhase(phase)
335+
336+ if !orch.IsComplete() {
337+ t.Error("IsComplete should be true when all phases completed")
338+ }
339+ })
340+
341+ t.Run("with skipped", func(t *testing.T) {
342+ orch := NewOrchestrator(cfg, "test.log")
343+ phase := newTestPhase("test", cfg)
344+ phase.SetStatus(StatusSkipped)
345+ orch.RegisterPhase(phase)
346+
347+ if !orch.IsComplete() {
348+ t.Error("IsComplete should be true when all phases skipped")
349+ }
350+ })
351+
352+ t.Run("with pending", func(t *testing.T) {
353+ orch := NewOrchestrator(cfg, "test.log")
354+ phase := newTestPhase("test", cfg)
355+ orch.RegisterPhase(phase)
356+
357+ if orch.IsComplete() {
358+ t.Error("IsComplete should be false with pending phases")
359+ }
360+ })
361+}
362+
363+// TestOrchestratorHasFailed tests failure detection
364+func TestOrchestratorHasFailed(t *testing.T) {
365+ cfg := config.NewConfig("test")
366+
367+ t.Run("no failures", func(t *testing.T) {
368+ orch := NewOrchestrator(cfg, "test.log")
369+ phase := newTestPhase("test", cfg)
370+ phase.SetStatus(StatusCompleted)
371+ orch.RegisterPhase(phase)
372+
373+ if orch.HasFailed() {
374+ t.Error("HasFailed should be false when no phases failed")
375+ }
376+ })
377+
378+ t.Run("with failure", func(t *testing.T) {
379+ orch := NewOrchestrator(cfg, "test.log")
380+ phase := newTestPhase("test", cfg)
381+ phase.SetStatus(StatusFailed)
382+ orch.RegisterPhase(phase)
383+
384+ if !orch.HasFailed() {
385+ t.Error("HasFailed should be true when phase failed")
386+ }
387+ })
388+}
389+
390+// TestOrchestratorReset tests Reset functionality
391+func TestOrchestratorReset(t *testing.T) {
392+ cfg := config.NewConfig("test")
393+ orch := NewOrchestrator(cfg, "test.log")
394+
395+ phase1 := newTestPhase("phase-1", cfg)
396+ phase2 := newTestPhase("phase-2", cfg)
397+
398+ phase1.SetStatus(StatusCompleted)
399+ phase2.SetStatus(StatusFailed)
400+
401+ orch.RegisterPhase(phase1)
402+ orch.RegisterPhase(phase2)
403+
404+ // Execute to set currentIdx
405+ orch.ExecutePhase(phase1)
406+
407+ // Reset
408+ orch.Reset()
409+
410+ // Verify currentIdx reset
411+ if orch.CurrentPhase() != nil {
412+ t.Error("CurrentPhase should be nil after Reset")
413+ }
414+
415+ // Verify statuses reset
416+ if phase1.Status() != StatusPending {
417+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusPending)
418+ }
419+ if phase2.Status() != StatusPending {
420+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusPending)
421+ }
422+}
423+
424+// TestOrchestratorExecuteAll tests full sequence execution
425+func TestOrchestratorExecuteAll(t *testing.T) {
426+ cfg := config.NewConfig("test")
427+ orch := NewOrchestrator(cfg, "test.log")
428+
429+ phase1 := newTestPhase("phase-1", cfg)
430+ phase2 := newTestPhase("phase-2", cfg)
431+ phase3 := newTestPhase("phase-3", cfg)
432+
433+ orch.RegisterPhase(phase1)
434+ orch.RegisterPhase(phase2)
435+ orch.RegisterPhase(phase3)
436+
437+ err := orch.ExecuteAll()
438+ if err != nil {
439+ t.Errorf("ExecuteAll failed: %v", err)
440+ }
441+
442+ // Verify all completed
443+ if phase1.Status() != StatusCompleted {
444+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
445+ }
446+ if phase2.Status() != StatusCompleted {
447+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusCompleted)
448+ }
449+ if phase3.Status() != StatusCompleted {
450+ t.Errorf("phase3 Status = %v, want %v", phase3.Status(), StatusCompleted)
451+ }
452+}
453+
454+// TestOrchestratorExecuteAllStopsOnFailure tests that ExecuteAll stops on first failure
455+func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) {
456+ cfg := config.NewConfig("test")
457+ orch := NewOrchestrator(cfg, "test.log")
458+
459+ phase1 := newTestPhase("phase-1", cfg)
460+ phase2 := newTestPhase("phase-2", cfg)
461+ phase3 := newTestPhase("phase-3", cfg)
462+
463+ // Make phase-2 fail
464+ phase2.executeErr = fmt.Errorf("execution failed")
465+
466+ orch.RegisterPhase(phase1)
467+ orch.RegisterPhase(phase2)
468+ orch.RegisterPhase(phase3)
469+
470+ err := orch.ExecuteAll()
471+ if err == nil {
472+ t.Error("Expected error from ExecuteAll when phase-2 fails")
473+ }
474+
475+ // Verify phase-1 completed
476+ if phase1.Status() != StatusCompleted {
477+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
478+ }
479+
480+ // Verify phase-2 failed
481+ if phase2.Status() != StatusFailed {
482+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusFailed)
483+ }
484+
485+ // Verify phase-3 never executed
486+ if phase3.Status() != StatusPending {
487+ t.Errorf("phase3 Status = %v, want %v (should not execute)", phase3.Status(), StatusPending)
488+ }
489+}
490+
491+// TestOrchestratorExecuteNext tests ExecuteNext
492+func TestOrchestratorExecuteNext(t *testing.T) {
493+ cfg := config.NewConfig("test")
494+ orch := NewOrchestrator(cfg, "test.log")
495+
496+ phase := newTestPhase("test", cfg)
497+ orch.RegisterPhase(phase)
498+
499+ err := orch.ExecuteNext()
500+ if err != nil {
501+ t.Errorf("ExecuteNext failed: %v", err)
502+ }
503+
504+ if phase.Status() != StatusCompleted {
505+ t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
506+ }
507+}
508+
509+// TestOrchestratorExecuteNextNoPending tests ExecuteNext with no pending phases
510+func TestOrchestratorExecuteNextNoPending(t *testing.T) {
511+ cfg := config.NewConfig("test")
512+ orch := NewOrchestrator(cfg, "test.log")
513+
514+ phase := newTestPhase("test", cfg)
515+ phase.SetStatus(StatusCompleted)
516+ orch.RegisterPhase(phase)
517+
518+ err := orch.ExecuteNext()
519+ if err == nil {
520+ t.Error("Expected error when no pending phases")
521+ }
522+}
523+
524+// TestOrchestratorPhaseDurations tests duration tracking
525+func TestOrchestratorPhaseDurations(t *testing.T) {
526+ cfg := config.NewConfig("test")
527+ orch := NewOrchestrator(cfg, "test.log")
528+
529+ phase := newTestPhase("test", cfg)
530+ orch.RegisterPhase(phase)
531+
532+ err := orch.ExecutePhase(phase)
533+ if err != nil {
534+ t.Fatalf("ExecutePhase failed: %v", err)
535+ }
536+
537+ durations := orch.GetPhaseDurations()
538+ duration, exists := durations["test"]
539+ if !exists {
540+ t.Error("Expected duration for 'test' phase")
541+ }
542+ if duration == 0 {
543+ t.Error("Duration should be greater than 0")
544+ }
545+}
M internal/phases/partitioning.go +13 -11
@@ -5,19 +5,21 @@ import (
5 "strings"5 "strings"
6 6
7 "github.com/bnema/archup/internal/config"7 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
8 "github.com/bnema/archup/internal/logger"9 "github.com/bnema/archup/internal/logger"
9- "github.com/bnema/archup/internal/system"
10 )10 )
11 11
12 // PartitioningPhase handles disk partitioning, formatting, and mounting12 // PartitioningPhase handles disk partitioning, formatting, and mounting
13 type PartitioningPhase struct {13 type PartitioningPhase struct {
14 *BasePhase14 *BasePhase
15+ sysExec interfaces.SystemExecutor
15 }16 }
16 17
17 // NewPartitioningPhase creates a new partitioning phase18 // NewPartitioningPhase creates a new partitioning phase
18-func NewPartitioningPhase(cfg *config.Config, log *logger.Logger) *PartitioningPhase {19+func NewPartitioningPhase(cfg *config.Config, log *logger.Logger, sysExec interfaces.SystemExecutor) *PartitioningPhase {
19 return &PartitioningPhase{20 return &PartitioningPhase{
20 BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log),21 BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log),
22+ sysExec: sysExec,
21 }23 }
22 }24 }
23 25
@@ -186,7 +188,7 @@ func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpda
186 188
187 // Note: In real implementation, pipe password securely via stdin189 // Note: In real implementation, pipe password securely via stdin
188 // For now, documenting the approach190 // For now, documenting the approach
189- result = system.RunSimple("sh", "-c",191+ result = p.sysExec.RunSimple("sh", "-c",
190 fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s",192 fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s",
191 password, p.config.RootPartition))193 password, p.config.RootPartition))
192 194
@@ -196,7 +198,7 @@ func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpda
196 }198 }
197 199
198 // Open LUKS container200 // Open LUKS container
199- result = system.RunSimple("sh", "-c",201+ result = p.sysExec.RunSimple("sh", "-c",
200 fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",202 fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",
201 password, p.config.RootPartition))203 password, p.config.RootPartition))
202 204
@@ -255,7 +257,7 @@ func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate)
255 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@")257 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@")
256 switch {258 switch {
257 case result.ExitCode != 0:259 case result.ExitCode != 0:
258- system.RunSimple("umount", "/mnt") // Cleanup260+ p.sysExec.RunSimple("umount", "/mnt") // Cleanup
259 return fmt.Errorf("@ subvolume creation failed: %w", result.Error)261 return fmt.Errorf("@ subvolume creation failed: %w", result.Error)
260 }262 }
261 263
@@ -263,7 +265,7 @@ func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate)
263 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home")265 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home")
264 switch {266 switch {
265 case result.ExitCode != 0:267 case result.ExitCode != 0:
266- system.RunSimple("umount", "/mnt") // Cleanup268+ p.sysExec.RunSimple("umount", "/mnt") // Cleanup
267 return fmt.Errorf("@home subvolume creation failed: %w", result.Error)269 return fmt.Errorf("@home subvolume creation failed: %w", result.Error)
268 }270 }
269 271
@@ -334,7 +336,7 @@ func (p *PartitioningPhase) mountFilesystems(progressChan chan<- ProgressUpdate)
334 // PostCheck validates mounts336 // PostCheck validates mounts
335 func (p *PartitioningPhase) PostCheck() error {337 func (p *PartitioningPhase) PostCheck() error {
336 // Verify /mnt is mounted338 // Verify /mnt is mounted
337- result := system.RunSimple("mountpoint", "-q", "/mnt")339+ result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
338 switch {340 switch {
339 case result.ExitCode != 0:341 case result.ExitCode != 0:
340 return fmt.Errorf("/mnt is not mounted")342 return fmt.Errorf("/mnt is not mounted")
@@ -346,16 +348,16 @@ func (p *PartitioningPhase) PostCheck() error {
346 // Rollback unmounts filesystems348 // Rollback unmounts filesystems
347 func (p *PartitioningPhase) Rollback() error {349 func (p *PartitioningPhase) Rollback() error {
348 // Unmount in reverse order350 // Unmount in reverse order
349- system.RunSimple("umount", "/mnt/boot")351+ p.sysExec.RunSimple("umount", "/mnt/boot")
350- system.RunSimple("umount", "/mnt/home")352+ p.sysExec.RunSimple("umount", "/mnt/home")
351- system.RunSimple("umount", "/mnt")353+ p.sysExec.RunSimple("umount", "/mnt")
352 354
353 // Close LUKS if encrypted355 // Close LUKS if encrypted
354 switch p.config.EncryptionType {356 switch p.config.EncryptionType {
355 case config.EncryptionLUKS, config.EncryptionLUKSLVM:357 case config.EncryptionLUKS, config.EncryptionLUKSLVM:
356 switch {358 switch {
357 case p.config.CryptDevice != "":359 case p.config.CryptDevice != "":
358- system.RunSimple("cryptsetup", "close", "cryptroot")360+ p.sysExec.RunSimple("cryptsetup", "close", "cryptroot")
359 }361 }
360 }362 }
361 363
A internal/phases/partitioning_test.go +523 -0
@@ -0,0 +1,523 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces/mocks"
11+ "github.com/bnema/archup/internal/logger"
12+ "github.com/bnema/archup/internal/system"
13+ "go.uber.org/mock/gomock"
14+)
15+
16+// TestPartitioningPhasePreCheck tests partitioning prerequisites
17+func TestPartitioningPhasePreCheck(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ targetDisk string
21+ wantErr bool
22+ errContains string
23+ }{
24+ {
25+ name: "Valid target disk",
26+ targetDisk: "/dev/sda",
27+ wantErr: false,
28+ errContains: "",
29+ },
30+ {
31+ name: "Missing target disk",
32+ targetDisk: "",
33+ wantErr: true,
34+ errContains: "target disk not selected",
35+ },
36+ {
37+ name: "NVMe target disk",
38+ targetDisk: "/dev/nvme0n1",
39+ wantErr: false,
40+ errContains: "",
41+ },
42+ }
43+
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ ctrl := gomock.NewController(t)
47+ defer ctrl.Finish()
48+
49+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
50+
51+ tmpDir := t.TempDir()
52+ logPath := filepath.Join(tmpDir, "test.log")
53+ log, err := logger.New(logPath, false)
54+ if err != nil {
55+ t.Fatalf("Failed to create logger: %v", err)
56+ }
57+ defer log.Close()
58+
59+ cfg := config.NewConfig("test")
60+ cfg.TargetDisk = tt.targetDisk
61+
62+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
63+
64+ err = phase.PreCheck()
65+ if (err != nil) != tt.wantErr {
66+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
67+ return
68+ }
69+ if tt.wantErr && err != nil {
70+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
71+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
72+ }
73+ }
74+ })
75+ }
76+}
77+
78+// TestPartitioningPhasePartitionNaming tests partition naming logic
79+func TestPartitioningPhasePartitionNaming(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ targetDisk string
83+ expectedEFI string
84+ expectedRoot string
85+ }{
86+ {
87+ name: "NVMe disk naming",
88+ targetDisk: "/dev/nvme0n1",
89+ expectedEFI: "/dev/nvme0n1p1",
90+ expectedRoot: "/dev/nvme0n1p2",
91+ },
92+ {
93+ name: "SATA disk naming",
94+ targetDisk: "/dev/sda",
95+ expectedEFI: "/dev/sda1",
96+ expectedRoot: "/dev/sda2",
97+ },
98+ {
99+ name: "VirtIO disk naming",
100+ targetDisk: "/dev/vda",
101+ expectedEFI: "/dev/vda1",
102+ expectedRoot: "/dev/vda2",
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ ctrl := gomock.NewController(t)
109+ defer ctrl.Finish()
110+
111+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
112+
113+ tmpDir := t.TempDir()
114+ logPath := filepath.Join(tmpDir, "test.log")
115+ log, err := logger.New(logPath, false)
116+ if err != nil {
117+ t.Fatalf("Failed to create logger: %v", err)
118+ }
119+ defer log.Close()
120+
121+ cfg := config.NewConfig("test")
122+ cfg.TargetDisk = tt.targetDisk
123+
124+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
125+
126+ // Verify partition names are set correctly in createPartitions logic
127+ // This is done by checking the naming convention
128+ phase.config.EFIPartition = tt.expectedEFI
129+ phase.config.RootPartition = tt.expectedRoot
130+
131+ if phase.config.EFIPartition != tt.expectedEFI {
132+ t.Errorf("EFI partition = %q, want %q", phase.config.EFIPartition, tt.expectedEFI)
133+ }
134+ if phase.config.RootPartition != tt.expectedRoot {
135+ t.Errorf("Root partition = %q, want %q", phase.config.RootPartition, tt.expectedRoot)
136+ }
137+ })
138+ }
139+}
140+
141+// TestPartitioningPhaseFormatRoot tests format method branching
142+func TestPartitioningPhaseFormatRoot(t *testing.T) {
143+ tests := []struct {
144+ name string
145+ encryptionType string
146+ expectedCallType string // "LUKS" or "Plain"
147+ }{
148+ {
149+ name: "Unencrypted root",
150+ encryptionType: config.EncryptionNone,
151+ expectedCallType: "Plain",
152+ },
153+ {
154+ name: "LUKS encrypted root",
155+ encryptionType: config.EncryptionLUKS,
156+ expectedCallType: "LUKS",
157+ },
158+ {
159+ name: "LUKS with LVM",
160+ encryptionType: config.EncryptionLUKSLVM,
161+ expectedCallType: "LUKS",
162+ },
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ ctrl := gomock.NewController(t)
168+ defer ctrl.Finish()
169+
170+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
171+
172+ tmpDir := t.TempDir()
173+ logPath := filepath.Join(tmpDir, "test.log")
174+ log, err := logger.New(logPath, false)
175+ if err != nil {
176+ t.Fatalf("Failed to create logger: %v", err)
177+ }
178+ defer log.Close()
179+
180+ cfg := config.NewConfig("test")
181+ cfg.TargetDisk = "/dev/sda"
182+ cfg.EFIPartition = "/dev/sda1"
183+ cfg.RootPartition = "/dev/sda2"
184+ cfg.EncryptionType = tt.encryptionType
185+ cfg.UserPassword = "password123"
186+
187+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
188+
189+ // Verify encryption type branching
190+ if phase.config.EncryptionType != tt.encryptionType {
191+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
192+ }
193+ })
194+ }
195+}
196+
197+// TestPartitioningPhaseEncryptionValidation tests encryption password requirement
198+func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
199+ tests := []struct {
200+ name string
201+ encryptionType string
202+ password string
203+ shouldRequire bool
204+ }{
205+ {
206+ name: "Unencrypted doesn't require password",
207+ encryptionType: config.EncryptionNone,
208+ password: "",
209+ shouldRequire: false,
210+ },
211+ {
212+ name: "LUKS requires password",
213+ encryptionType: config.EncryptionLUKS,
214+ password: "secure123",
215+ shouldRequire: true,
216+ },
217+ {
218+ name: "LUKS with empty password fails validation",
219+ encryptionType: config.EncryptionLUKS,
220+ password: "",
221+ shouldRequire: true, // Should have required one
222+ },
223+ }
224+
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ ctrl := gomock.NewController(t)
228+ defer ctrl.Finish()
229+
230+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
231+
232+ tmpDir := t.TempDir()
233+ logPath := filepath.Join(tmpDir, "test.log")
234+ log, err := logger.New(logPath, false)
235+ if err != nil {
236+ t.Fatalf("Failed to create logger: %v", err)
237+ }
238+ defer log.Close()
239+
240+ cfg := config.NewConfig("test")
241+ cfg.TargetDisk = "/dev/sda"
242+ cfg.EFIPartition = "/dev/sda1"
243+ cfg.RootPartition = "/dev/sda2"
244+ cfg.EncryptionType = tt.encryptionType
245+ cfg.UserPassword = tt.password
246+
247+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
248+
249+ // Validate password requirement
250+ if tt.shouldRequire && tt.encryptionType != config.EncryptionNone {
251+ if phase.config.UserPassword == "" && tt.password == "" {
252+ // Empty password for encryption - should fail in actual execution
253+ if phase.config.EncryptionType != config.EncryptionNone {
254+ // Password is required for encryption
255+ }
256+ }
257+ }
258+ })
259+ }
260+}
261+
262+// TestPartitioningPhasePostCheck tests mount point validation
263+func TestPartitioningPhasePostCheck(t *testing.T) {
264+ tests := []struct {
265+ name string
266+ setupMocks func(*mocks.MockSystemExecutor)
267+ wantErr bool
268+ errContains string
269+ }{
270+ {
271+ name: "/mnt is mounted - postcheck passes",
272+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
273+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 0}).Times(1)
274+ },
275+ wantErr: false,
276+ },
277+ {
278+ name: "/mnt not mounted - postcheck fails",
279+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
280+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
281+ },
282+ wantErr: true,
283+ errContains: "is not mounted",
284+ },
285+ }
286+
287+ for _, tt := range tests {
288+ t.Run(tt.name, func(t *testing.T) {
289+ ctrl := gomock.NewController(t)
290+ defer ctrl.Finish()
291+
292+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
293+
294+ tt.setupMocks(mockSysExec)
295+
296+ tmpDir := t.TempDir()
297+ logPath := filepath.Join(tmpDir, "test.log")
298+ log, err := logger.New(logPath, false)
299+ if err != nil {
300+ t.Fatalf("Failed to create logger: %v", err)
301+ }
302+ defer log.Close()
303+
304+ cfg := config.NewConfig("test")
305+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
306+
307+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
308+
309+ err = phase.PostCheck()
310+ if (err != nil) != tt.wantErr {
311+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
312+ return
313+ }
314+ if tt.wantErr && err != nil {
315+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
316+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
317+ }
318+ }
319+ })
320+ }
321+}
322+
323+// TestPartitioningPhaseRollback tests rollback cleanup
324+func TestPartitioningPhaseRollback(t *testing.T) {
325+ tests := []struct {
326+ name string
327+ setupMocks func(*mocks.MockSystemExecutor)
328+ encryption string
329+ cryptDevice string
330+ wantErr bool
331+ }{
332+ {
333+ name: "Rollback unencrypted - success",
334+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
335+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
336+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
337+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
338+ },
339+ encryption: config.EncryptionNone,
340+ cryptDevice: "",
341+ wantErr: false,
342+ },
343+ {
344+ name: "Rollback encrypted - closes LUKS",
345+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
346+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
347+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
348+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
349+ mockSysExec.EXPECT().RunSimple("cryptsetup", "close", "cryptroot").Return(system.CommandResult{}).Times(1)
350+ },
351+ encryption: config.EncryptionLUKS,
352+ cryptDevice: "/dev/mapper/cryptroot",
353+ wantErr: false,
354+ },
355+ {
356+ name: "Rollback umount failure - continues anyway",
357+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
358+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{ExitCode: 32, Error: fmt.Errorf("not mounted")}).Times(1)
359+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{ExitCode: 32}).Times(1)
360+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
361+ },
362+ encryption: config.EncryptionNone,
363+ cryptDevice: "",
364+ wantErr: false, // Rollback doesn't fail on umount errors
365+ },
366+ }
367+
368+ for _, tt := range tests {
369+ t.Run(tt.name, func(t *testing.T) {
370+ ctrl := gomock.NewController(t)
371+ defer ctrl.Finish()
372+
373+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
374+
375+ tt.setupMocks(mockSysExec)
376+
377+ tmpDir := t.TempDir()
378+ logPath := filepath.Join(tmpDir, "test.log")
379+ log, err := logger.New(logPath, false)
380+ if err != nil {
381+ t.Fatalf("Failed to create logger: %v", err)
382+ }
383+ defer log.Close()
384+
385+ cfg := config.NewConfig("test")
386+ cfg.EncryptionType = tt.encryption
387+ cfg.CryptDevice = tt.cryptDevice
388+
389+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
390+
391+ err = phase.Rollback()
392+ if (err != nil) != tt.wantErr {
393+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
394+ }
395+ })
396+ }
397+}
398+
399+// TestPartitioningPhaseCanSkip tests the CanSkip method
400+func TestPartitioningPhaseCanSkip(t *testing.T) {
401+ ctrl := gomock.NewController(t)
402+ defer ctrl.Finish()
403+
404+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
405+
406+ tmpDir := t.TempDir()
407+ logPath := filepath.Join(tmpDir, "test.log")
408+ log, err := logger.New(logPath, false)
409+ if err != nil {
410+ t.Fatalf("Failed to create logger: %v", err)
411+ }
412+ defer log.Close()
413+
414+ cfg := config.NewConfig("test")
415+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
416+
417+ if phase.CanSkip() {
418+ t.Error("CanSkip() = true, want false")
419+ }
420+}
421+
422+// TestPartitioningPhaseExecute tests the Execute method structure
423+func TestPartitioningPhaseExecute(t *testing.T) {
424+ ctrl := gomock.NewController(t)
425+ defer ctrl.Finish()
426+
427+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
428+
429+ tmpDir := t.TempDir()
430+ logPath := filepath.Join(tmpDir, "test.log")
431+ log, err := logger.New(logPath, false)
432+ if err != nil {
433+ t.Fatalf("Failed to create logger: %v", err)
434+ }
435+ defer log.Close()
436+
437+ cfg := config.NewConfig("test")
438+ cfg.TargetDisk = "/dev/sda"
439+
440+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
441+
442+ progressChan := make(chan ProgressUpdate, 100)
443+
444+ // Verify phase can be created and Execute returns a PhaseResult
445+ // Note: Full Execute testing is complex due to logger.ExecCommand() calls
446+ // The return type and structure are validated here
447+ result := phase.Execute(progressChan)
448+
449+ // Verify result has the expected type
450+ if reflect.TypeOf(result) != reflect.TypeOf(PhaseResult{}) {
451+ t.Errorf("Execute() returned unexpected type: %T", result)
452+ }
453+}
454+
455+// TestPartitioningPhaseDeviceHandling tests both encrypted and unencrypted device handling
456+func TestPartitioningPhaseDeviceHandling(t *testing.T) {
457+ tests := []struct {
458+ name string
459+ encryptionType string
460+ cryptDevice string
461+ rootPartition string
462+ expectedDevice string
463+ }{
464+ {
465+ name: "Unencrypted uses root partition",
466+ encryptionType: config.EncryptionNone,
467+ cryptDevice: "",
468+ rootPartition: "/dev/sda2",
469+ expectedDevice: "/dev/sda2",
470+ },
471+ {
472+ name: "Encrypted uses crypt device",
473+ encryptionType: config.EncryptionLUKS,
474+ cryptDevice: "/dev/mapper/cryptroot",
475+ rootPartition: "/dev/sda2",
476+ expectedDevice: "/dev/mapper/cryptroot",
477+ },
478+ {
479+ name: "LUKS+LVM uses crypt device",
480+ encryptionType: config.EncryptionLUKSLVM,
481+ cryptDevice: "/dev/mapper/cryptroot",
482+ rootPartition: "/dev/sda2",
483+ expectedDevice: "/dev/mapper/cryptroot",
484+ },
485+ }
486+
487+ for _, tt := range tests {
488+ t.Run(tt.name, func(t *testing.T) {
489+ ctrl := gomock.NewController(t)
490+ defer ctrl.Finish()
491+
492+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
493+
494+ tmpDir := t.TempDir()
495+ logPath := filepath.Join(tmpDir, "test.log")
496+ log, err := logger.New(logPath, false)
497+ if err != nil {
498+ t.Fatalf("Failed to create logger: %v", err)
499+ }
500+ defer log.Close()
501+
502+ cfg := config.NewConfig("test")
503+ cfg.EncryptionType = tt.encryptionType
504+ cfg.CryptDevice = tt.cryptDevice
505+ cfg.RootPartition = tt.rootPartition
506+
507+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
508+
509+ // Determine which device would be used
510+ var device string
511+ switch phase.config.EncryptionType {
512+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
513+ device = phase.config.CryptDevice
514+ default:
515+ device = phase.config.RootPartition
516+ }
517+
518+ if device != tt.expectedDevice {
519+ t.Errorf("Device selection = %q, want %q", device, tt.expectedDevice)
520+ }
521+ })
522+ }
523+}
A internal/phases/phase_test.go +328 -0
@@ -0,0 +1,328 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/logger"
10+)
11+
12+// TestPhaseStatusString tests the String() method of PhaseStatus enum
13+func TestPhaseStatusString(t *testing.T) {
14+ tests := []struct {
15+ status PhaseStatus
16+ expected string
17+ }{
18+ {StatusPending, "pending"},
19+ {StatusRunning, "running"},
20+ {StatusCompleted, "completed"},
21+ {StatusFailed, "failed"},
22+ {StatusSkipped, "skipped"},
23+ {PhaseStatus(999), "unknown"},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.expected, func(t *testing.T) {
28+ got := tt.status.String()
29+ if got != tt.expected {
30+ t.Errorf("PhaseStatus.String() = %q, want %q", got, tt.expected)
31+ }
32+ })
33+ }
34+}
35+
36+// TestNewBasePhase verifies BasePhase initialization
37+func TestNewBasePhase(t *testing.T) {
38+ tmpDir := t.TempDir()
39+ logPath := filepath.Join(tmpDir, "test.log")
40+
41+ log, err := logger.New(logPath, false)
42+ if err != nil {
43+ t.Fatalf("Failed to create logger: %v", err)
44+ }
45+ defer log.Close()
46+
47+ cfg := config.NewConfig("test")
48+
49+ phase := NewBasePhase("test-phase", "Test phase description", cfg, log)
50+
51+ if phase.Name() != "test-phase" {
52+ t.Errorf("Name() = %q, want %q", phase.Name(), "test-phase")
53+ }
54+
55+ if phase.Description() != "Test phase description" {
56+ t.Errorf("Description() = %q, want %q", phase.Description(), "Test phase description")
57+ }
58+
59+ if phase.Status() != StatusPending {
60+ t.Errorf("Status() = %v, want %v", phase.Status(), StatusPending)
61+ }
62+
63+ if phase.Config() != cfg {
64+ t.Errorf("Config() returned different instance")
65+ }
66+
67+ if phase.Logger() != log {
68+ t.Errorf("Logger() returned different instance")
69+ }
70+}
71+
72+// TestBasePhaseSetStatus verifies status updates
73+func TestBasePhaseSetStatus(t *testing.T) {
74+ tmpDir := t.TempDir()
75+ logPath := filepath.Join(tmpDir, "test.log")
76+
77+ log, _ := logger.New(logPath, false)
78+ defer log.Close()
79+
80+ cfg := config.NewConfig("test")
81+ phase := NewBasePhase("test", "test", cfg, log)
82+
83+ statuses := []PhaseStatus{
84+ StatusPending,
85+ StatusRunning,
86+ StatusCompleted,
87+ StatusFailed,
88+ StatusSkipped,
89+ }
90+
91+ for _, status := range statuses {
92+ t.Run(status.String(), func(t *testing.T) {
93+ phase.SetStatus(status)
94+ if phase.Status() != status {
95+ t.Errorf("Status() = %v, want %v", phase.Status(), status)
96+ }
97+ })
98+ }
99+}
100+
101+// TestBasePhaseDefaultPreCheck verifies default PreCheck implementation
102+func TestBasePhaseDefaultPreCheck(t *testing.T) {
103+ tmpDir := t.TempDir()
104+ logPath := filepath.Join(tmpDir, "test.log")
105+
106+ log, _ := logger.New(logPath, false)
107+ defer log.Close()
108+
109+ tests := []struct {
110+ name string
111+ cfg *config.Config
112+ wantError bool
113+ }{
114+ {
115+ name: "with valid config",
116+ cfg: config.NewConfig("test"),
117+ wantError: false,
118+ },
119+ {
120+ name: "with nil config",
121+ cfg: nil,
122+ wantError: true,
123+ },
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ phase := NewBasePhase("test", "test", tt.cfg, log)
129+ err := phase.PreCheck()
130+
131+ if (err != nil) != tt.wantError {
132+ t.Errorf("PreCheck() error = %v, wantError %v", err, tt.wantError)
133+ }
134+ })
135+ }
136+}
137+
138+// TestBasePhaseDefaultPostCheck verifies default PostCheck (always succeeds)
139+func TestBasePhaseDefaultPostCheck(t *testing.T) {
140+ tmpDir := t.TempDir()
141+ logPath := filepath.Join(tmpDir, "test.log")
142+
143+ log, _ := logger.New(logPath, false)
144+ defer log.Close()
145+
146+ cfg := config.NewConfig("test")
147+ phase := NewBasePhase("test", "test", cfg, log)
148+
149+ err := phase.PostCheck()
150+ if err != nil {
151+ t.Errorf("PostCheck() error = %v, want nil", err)
152+ }
153+}
154+
155+// TestBasePhaseDefaultRollback verifies default Rollback returns error
156+func TestBasePhaseDefaultRollback(t *testing.T) {
157+ tmpDir := t.TempDir()
158+ logPath := filepath.Join(tmpDir, "test.log")
159+
160+ log, _ := logger.New(logPath, false)
161+ defer log.Close()
162+
163+ cfg := config.NewConfig("test")
164+ phase := NewBasePhase("my-phase", "test", cfg, log)
165+
166+ err := phase.Rollback()
167+ if err == nil {
168+ t.Error("Rollback() error = nil, want error")
169+ }
170+
171+ expectedMsg := "rollback not implemented for phase: my-phase"
172+ if err.Error() != expectedMsg {
173+ t.Errorf("Rollback() error = %q, want %q", err.Error(), expectedMsg)
174+ }
175+}
176+
177+// TestBasePhaseDefaultCanSkip verifies default CanSkip returns false
178+func TestBasePhaseDefaultCanSkip(t *testing.T) {
179+ tmpDir := t.TempDir()
180+ logPath := filepath.Join(tmpDir, "test.log")
181+
182+ log, _ := logger.New(logPath, false)
183+ defer log.Close()
184+
185+ cfg := config.NewConfig("test")
186+ phase := NewBasePhase("test", "test", cfg, log)
187+
188+ if phase.CanSkip() {
189+ t.Error("CanSkip() = true, want false")
190+ }
191+}
192+
193+// TestSendProgress verifies progress updates sent to channel
194+func TestSendProgress(t *testing.T) {
195+ tmpDir := t.TempDir()
196+ logPath := filepath.Join(tmpDir, "test.log")
197+
198+ log, _ := logger.New(logPath, false)
199+ defer log.Close()
200+
201+ cfg := config.NewConfig("test")
202+ phase := NewBasePhase("test-phase", "test", cfg, log)
203+
204+ progressChan := make(chan ProgressUpdate, 5)
205+
206+ phase.SendProgress(progressChan, "step-1", 1, 3)
207+ phase.SendProgress(progressChan, "step-2", 2, 3)
208+
209+ // Verify first update
210+ update1 := <-progressChan
211+ if update1.PhaseName != "test-phase" {
212+ t.Errorf("PhaseName = %q, want %q", update1.PhaseName, "test-phase")
213+ }
214+ if update1.Step != "step-1" {
215+ t.Errorf("Step = %q, want %q", update1.Step, "step-1")
216+ }
217+ if update1.Current != 1 || update1.Total != 3 {
218+ t.Errorf("Progress = %d/%d, want 1/3", update1.Current, update1.Total)
219+ }
220+ if update1.IsComplete || update1.IsError {
221+ t.Error("Flags should be false for regular progress update")
222+ }
223+
224+ // Verify second update
225+ update2 := <-progressChan
226+ if update2.Step != "step-2" {
227+ t.Errorf("Step = %q, want %q", update2.Step, "step-2")
228+ }
229+ if update2.Current != 2 {
230+ t.Errorf("Current = %d, want 2", update2.Current)
231+ }
232+}
233+
234+// TestSendProgressWithNilChannel verifies no panic with nil channel
235+func TestSendProgressWithNilChannel(t *testing.T) {
236+ tmpDir := t.TempDir()
237+ logPath := filepath.Join(tmpDir, "test.log")
238+
239+ log, _ := logger.New(logPath, false)
240+ defer log.Close()
241+
242+ cfg := config.NewConfig("test")
243+ phase := NewBasePhase("test", "test", cfg, log)
244+
245+ // Should not panic with nil channel
246+ phase.SendProgress(nil, "step", 1, 2)
247+}
248+
249+// TestSendComplete verifies completion signal
250+func TestSendComplete(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ logPath := filepath.Join(tmpDir, "test.log")
253+
254+ log, _ := logger.New(logPath, false)
255+ defer log.Close()
256+
257+ cfg := config.NewConfig("test")
258+ phase := NewBasePhase("test-phase", "test", cfg, log)
259+
260+ progressChan := make(chan ProgressUpdate, 1)
261+
262+ phase.SendComplete(progressChan, "Installation complete")
263+
264+ update := <-progressChan
265+ if !update.IsComplete {
266+ t.Error("IsComplete = false, want true")
267+ }
268+ if update.IsError {
269+ t.Error("IsError = true, want false")
270+ }
271+ if update.Step != "Installation complete" {
272+ t.Errorf("Step = %q, want %q", update.Step, "Installation complete")
273+ }
274+}
275+
276+// TestSendError verifies error signal
277+func TestSendError(t *testing.T) {
278+ tmpDir := t.TempDir()
279+ logPath := filepath.Join(tmpDir, "test.log")
280+
281+ log, _ := logger.New(logPath, false)
282+ defer log.Close()
283+
284+ cfg := config.NewConfig("test")
285+ phase := NewBasePhase("test-phase", "test", cfg, log)
286+
287+ progressChan := make(chan ProgressUpdate, 1)
288+
289+ testErr := "test error message"
290+ err := fmt.Errorf("%s", testErr)
291+ phase.SendError(progressChan, err)
292+
293+ update := <-progressChan
294+ if !update.IsComplete {
295+ t.Error("IsComplete = false, want true")
296+ }
297+ if !update.IsError {
298+ t.Error("IsError = false, want true")
299+ }
300+ if update.ErrorMsg != testErr {
301+ t.Errorf("ErrorMsg = %q, want %q", update.ErrorMsg, testErr)
302+ }
303+}
304+
305+// TestSendOutput verifies output line sending
306+func TestSendOutput(t *testing.T) {
307+ tmpDir := t.TempDir()
308+ logPath := filepath.Join(tmpDir, "test.log")
309+
310+ log, _ := logger.New(logPath, false)
311+ defer log.Close()
312+
313+ cfg := config.NewConfig("test")
314+ phase := NewBasePhase("test-phase", "test", cfg, log)
315+
316+ progressChan := make(chan ProgressUpdate, 1)
317+
318+ outputLine := "output from command"
319+ phase.SendOutput(progressChan, outputLine)
320+
321+ update := <-progressChan
322+ if update.OutputLine != outputLine {
323+ t.Errorf("OutputLine = %q, want %q", update.OutputLine, outputLine)
324+ }
325+ if update.IsComplete || update.IsError {
326+ t.Error("Completion/Error flags should be false for output update")
327+ }
328+}
M internal/phases/postinstall.go +54 -45
@@ -10,6 +10,7 @@ import (
10 "strings"10 "strings"
11 11
12 "github.com/bnema/archup/internal/config"12 "github.com/bnema/archup/internal/config"
13+ "github.com/bnema/archup/internal/interfaces"
13 "github.com/bnema/archup/internal/logger"14 "github.com/bnema/archup/internal/logger"
14 "github.com/bnema/archup/internal/system"15 "github.com/bnema/archup/internal/system"
15 )16 )
@@ -17,27 +18,35 @@ import (
17 // PostInstallPhase handles post-installation tasks18 // PostInstallPhase handles post-installation tasks
18 type PostInstallPhase struct {19 type PostInstallPhase struct {
19 *BasePhase20 *BasePhase
21+ fs interfaces.FileSystem
22+ http interfaces.HTTPClient
23+ sysExec interfaces.SystemExecutor
24+ chrExec interfaces.ChrootExecutor
20 }25 }
21 26
22 // NewPostInstallPhase creates a new post-install phase27 // NewPostInstallPhase creates a new post-install phase
23-func NewPostInstallPhase(cfg *config.Config, log *logger.Logger) *PostInstallPhase {28+func NewPostInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, http interfaces.HTTPClient, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *PostInstallPhase {
24 return &PostInstallPhase{29 return &PostInstallPhase{
25 BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log),30 BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log),
31+ fs: fs,
32+ http: http,
33+ sysExec: sysExec,
34+ chrExec: chrExec,
26 }35 }
27 }36 }
28 37
29 // PreCheck validates post-install prerequisites38 // PreCheck validates post-install prerequisites
30 func (p *PostInstallPhase) PreCheck() error {39 func (p *PostInstallPhase) PreCheck() error {
31 // Verify /mnt is mounted40 // Verify /mnt is mounted
32- result := system.RunSimple("mountpoint", "-q", config.PathMnt)41+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
33 switch {42 switch {
34 case result.ExitCode != 0:43 case result.ExitCode != 0:
35 return fmt.Errorf("%s is not mounted", config.PathMnt)44 return fmt.Errorf("%s is not mounted", config.PathMnt)
36 }45 }
37 46
38 // Verify boot directory exists47 // Verify boot directory exists
39- switch _, err := os.Stat(config.PathMntBoot); {48+ switch _, err := p.fs.Stat(config.PathMntBoot); {
40- case os.IsNotExist(err):49+ case p.fs.IsNotExist(err):
41 return fmt.Errorf("boot directory not found")50 return fmt.Errorf("boot directory not found")
42 }51 }
43 52
@@ -140,7 +149,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
140 // Build logo URL from config149 // Build logo URL from config
141 logoURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.ArchLogoURL)150 logoURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.ArchLogoURL)
142 151
143- resp, err := http.Get(logoURL)152+ resp, err := p.http.Get(logoURL)
144 switch {153 switch {
145 case err != nil:154 case err != nil:
146 return fmt.Errorf("failed to download logo: %w", err)155 return fmt.Errorf("failed to download logo: %w", err)
@@ -153,7 +162,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
153 }162 }
154 163
155 // Save to boot partition164 // Save to boot partition
156- logoFile, err := os.Create(config.PathMntBootLogo)165+ logoFile, err := p.fs.Create(config.PathMntBootLogo)
157 switch {166 switch {
158 case err != nil:167 case err != nil:
159 return fmt.Errorf("failed to create logo file: %w", err)168 return fmt.Errorf("failed to create logo file: %w", err)
@@ -170,7 +179,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
170 179
171 // Update Limine config180 // Update Limine config
172 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)181 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
173- content, err := os.ReadFile(limineConf)182+ content, err := p.fs.ReadFile(limineConf)
174 switch {183 switch {
175 case err != nil:184 case err != nil:
176 return fmt.Errorf("failed to read limine.conf: %w", err)185 return fmt.Errorf("failed to read limine.conf: %w", err)
@@ -185,7 +194,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
185 wallpaperSettings := "\nwallpaper: boot():/arch-logo.png\nwallpaper_style: centered\nbackdrop: 000000"194 wallpaperSettings := "\nwallpaper: boot():/arch-logo.png\nwallpaper_style: centered\nbackdrop: 000000"
186 contentStr = graphicsRegex.ReplaceAllString(contentStr, "graphics: yes"+wallpaperSettings)195 contentStr = graphicsRegex.ReplaceAllString(contentStr, "graphics: yes"+wallpaperSettings)
187 196
188- switch err := os.WriteFile(limineConf, []byte(contentStr), 0644); {197+ switch err := p.fs.WriteFile(limineConf, []byte(contentStr), 0644); {
189 case err != nil:198 case err != nil:
190 return fmt.Errorf("failed to update limine.conf: %w", err)199 return fmt.Errorf("failed to update limine.conf: %w", err)
191 }200 }
@@ -204,7 +213,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
204 213
205 // Create theme directory214 // Create theme directory
206 themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName)215 themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName)
207- switch err := os.MkdirAll(themeDir, 0755); {216+ switch err := p.fs.MkdirAll(themeDir, 0755); {
208 case err != nil:217 case err != nil:
209 return fmt.Errorf("failed to create theme directory: %w", err)218 return fmt.Errorf("failed to create theme directory: %w", err)
210 }219 }
@@ -214,7 +223,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
214 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", filename))223 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", filename))
215 224
216 fileURL := fmt.Sprintf("%s/assets/plymouth/%s", p.config.RawURL, filename)225 fileURL := fmt.Sprintf("%s/assets/plymouth/%s", p.config.RawURL, filename)
217- resp, err := http.Get(fileURL)226+ resp, err := p.http.Get(fileURL)
218 switch {227 switch {
219 case err != nil:228 case err != nil:
220 return fmt.Errorf("failed to download %s: %w", filename, err)229 return fmt.Errorf("failed to download %s: %w", filename, err)
@@ -228,7 +237,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
228 237
229 // Save file238 // Save file
230 destPath := filepath.Join(themeDir, filename)239 destPath := filepath.Join(themeDir, filename)
231- destFile, err := os.Create(destPath)240+ destFile, err := p.fs.Create(destPath)
232 switch {241 switch {
233 case err != nil:242 case err != nil:
234 resp.Body.Close()243 resp.Body.Close()
@@ -245,7 +254,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
245 }254 }
246 255
247 // Set permissions256 // Set permissions
248- switch err := os.Chmod(destPath, 0644); {257+ switch err := p.fs.Chmod(destPath, 0644); {
249 case err != nil:258 case err != nil:
250 return fmt.Errorf("failed to set permissions on %s: %w", filename, err)259 return fmt.Errorf("failed to set permissions on %s: %w", filename, err)
251 }260 }
@@ -255,7 +264,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
255 264
256 // Set as default theme265 // Set as default theme
257 setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName)266 setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName)
258- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, setThemeCmd); {267+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, setThemeCmd); {
259 case err != nil:268 case err != nil:
260 return fmt.Errorf("failed to set Plymouth theme: %w", err)269 return fmt.Errorf("failed to set Plymouth theme: %w", err)
261 }270 }
@@ -264,7 +273,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
264 273
265 // Regenerate initramfs274 // Regenerate initramfs
266 p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...")275 p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...")
267- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {276+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
268 case err != nil:277 case err != nil:
269 return fmt.Errorf("failed to regenerate initramfs: %w", err)278 return fmt.Errorf("failed to regenerate initramfs: %w", err)
270 }279 }
@@ -278,7 +287,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
278 p.SendOutput(progressChan, "Installing limine-snapper-sync...")287 p.SendOutput(progressChan, "Installing limine-snapper-sync...")
279 288
280 // Install limine-snapper-sync289 // Install limine-snapper-sync
281- switch err := system.ChrootPacman(p.logger.LogPath(),config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {290+ switch err := p.chrExec.ChrootPacman(p.logger.LogPath(),config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {
282 case err != nil:291 case err != nil:
283 return fmt.Errorf("failed to install limine-snapper-sync: %w", err)292 return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
284 }293 }
@@ -287,7 +296,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
287 296
288 // Get kernel cmdline from existing Limine config297 // Get kernel cmdline from existing Limine config
289 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)298 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
290- content, err := os.ReadFile(limineConf)299+ content, err := p.fs.ReadFile(limineConf)
291 switch {300 switch {
292 case err != nil:301 case err != nil:
293 return fmt.Errorf("failed to read limine.conf: %w", err)302 return fmt.Errorf("failed to read limine.conf: %w", err)
@@ -321,7 +330,7 @@ MAX_SNAPSHOT_ENTRIES=5
321 SNAPSHOT_FORMAT_CHOICE=5330 SNAPSHOT_FORMAT_CHOICE=5
322 `, cmdline)331 `, cmdline)
323 332
324- switch err := os.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {333+ switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
325 case err != nil:334 case err != nil:
326 return fmt.Errorf("failed to write limine config: %w", err)335 return fmt.Errorf("failed to write limine config: %w", err)
327 }336 }
@@ -329,7 +338,7 @@ SNAPSHOT_FORMAT_CHOICE=5
329 p.SendOutput(progressChan, "[OK] Limine snapper config created")338 p.SendOutput(progressChan, "[OK] Limine snapper config created")
330 339
331 // Enable service340 // Enable service
332- switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {341+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {
333 case err != nil:342 case err != nil:
334 return fmt.Errorf("failed to enable limine-snapper-sync: %w", err)343 return fmt.Errorf("failed to enable limine-snapper-sync: %w", err)
335 }344 }
@@ -342,7 +351,7 @@ SNAPSHOT_FORMAT_CHOICE=5
342 func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {351 func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
343 p.SendOutput(progressChan, "Configuring pacman UX...")352 p.SendOutput(progressChan, "Configuring pacman UX...")
344 353
345- content, err := os.ReadFile(config.PathMntEtcPacmanConf)354+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
346 switch {355 switch {
347 case err != nil:356 case err != nil:
348 return fmt.Errorf("failed to read pacman.conf: %w", err)357 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -365,7 +374,7 @@ func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) e
365 contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy")374 contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy")
366 }375 }
367 376
368- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {377+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
369 case err != nil:378 case err != nil:
370 return fmt.Errorf("failed to write pacman.conf: %w", err)379 return fmt.Errorf("failed to write pacman.conf: %w", err)
371 }380 }
@@ -379,7 +388,7 @@ func (p *PostInstallPhase) installPacmanHooks(progressChan chan<- ProgressUpdate
379 p.SendOutput(progressChan, "Installing pacman hooks...")388 p.SendOutput(progressChan, "Installing pacman hooks...")
380 389
381 // Create hooks directory390 // Create hooks directory
382- switch err := os.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {391+ switch err := p.fs.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {
383 case err != nil:392 case err != nil:
384 return fmt.Errorf("failed to create hooks directory: %w", err)393 return fmt.Errorf("failed to create hooks directory: %w", err)
385 }394 }
@@ -398,7 +407,7 @@ Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/
398 `407 `
399 408
400 hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook")409 hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook")
401- switch err := os.WriteFile(hookPath, []byte(limineHook), 0644); {410+ switch err := p.fs.WriteFile(hookPath, []byte(limineHook), 0644); {
402 case err != nil:411 case err != nil:
403 return fmt.Errorf("failed to write limine hook: %w", err)412 return fmt.Errorf("failed to write limine hook: %w", err)
404 }413 }
@@ -429,7 +438,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
429 p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username))438 p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username))
430 439
431 // Create directory structure440 // Create directory structure
432- switch err := os.MkdirAll(archupDefaultBash, 0755); {441+ switch err := p.fs.MkdirAll(archupDefaultBash, 0755); {
433 case err != nil:442 case err != nil:
434 return fmt.Errorf("failed to create shell config directory: %w", err)443 return fmt.Errorf("failed to create shell config directory: %w", err)
435 }444 }
@@ -448,7 +457,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
448 }457 }
449 458
450 bashrcPath := filepath.Join(userHome, ".bashrc")459 bashrcPath := filepath.Join(userHome, ".bashrc")
451- switch err := os.WriteFile(bashrcPath, bashrcContent, 0644); {460+ switch err := p.fs.WriteFile(bashrcPath, bashrcContent, 0644); {
452 case err != nil:461 case err != nil:
453 return fmt.Errorf("failed to write .bashrc: %w", err)462 return fmt.Errorf("failed to write .bashrc: %w", err)
454 }463 }
@@ -471,7 +480,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
471 }480 }
472 481
473 for _, gitCmd := range gitCommands {482 for _, gitCmd := range gitCommands {
474- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, gitCmd); {483+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, gitCmd); {
475 case err != nil:484 case err != nil:
476 // Non-fatal485 // Non-fatal
477 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")486 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
@@ -501,7 +510,7 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
501 return fmt.Errorf("failed to read template %s: %w", template, err)510 return fmt.Errorf("failed to read template %s: %w", template, err)
502 }511 }
503 512
504- switch err := os.WriteFile(dest, content, 0644); {513+ switch err := p.fs.WriteFile(dest, content, 0644); {
505 case err != nil:514 case err != nil:
506 return fmt.Errorf("failed to write %s: %w", dest, err)515 return fmt.Errorf("failed to write %s: %w", dest, err)
507 }516 }
@@ -514,7 +523,7 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
514 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {523 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
515 // Use DefaultInstallDir directly to match where bootstrap downloads files524 // Use DefaultInstallDir directly to match where bootstrap downloads files
516 templatePath := filepath.Join(config.DefaultInstallDir, filename)525 templatePath := filepath.Join(config.DefaultInstallDir, filename)
517- return os.ReadFile(templatePath)526+ return p.fs.ReadFile(templatePath)
518 }527 }
519 528
520 // setShellOwnership sets ownership of shell config files to user529 // setShellOwnership sets ownership of shell config files to user
@@ -523,7 +532,7 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
523 relativeHome := strings.TrimPrefix(userHome, config.PathMnt)532 relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
524 chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",533 chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
525 username, username, relativeHome, relativeHome)534 username, username, relativeHome, relativeHome)
526- return system.ChrootExec(p.logger.LogPath(),config.PathMnt, chownCmd)535+ return p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, chownCmd)
527 }536 }
528 537
529 // verifyInstallation performs comprehensive verification538 // verifyInstallation performs comprehensive verification
@@ -535,8 +544,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
535 // Verify kernel544 // Verify kernel
536 kernelName := p.config.KernelChoice545 kernelName := p.config.KernelChoice
537 kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName))546 kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName))
538- switch _, err := os.Stat(kernelPath); {547+ switch _, err := p.fs.Stat(kernelPath); {
539- case os.IsNotExist(err):548+ case p.fs.IsNotExist(err):
540 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath))549 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath))
541 verificationFailed = true550 verificationFailed = true
542 default:551 default:
@@ -545,8 +554,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
545 554
546 // Verify initramfs555 // Verify initramfs
547 initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName))556 initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName))
548- switch _, err := os.Stat(initramfsPath); {557+ switch _, err := p.fs.Stat(initramfsPath); {
549- case os.IsNotExist(err):558+ case p.fs.IsNotExist(err):
550 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath))559 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath))
551 verificationFailed = true560 verificationFailed = true
552 default:561 default:
@@ -555,8 +564,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
555 564
556 // Verify bootloader565 // Verify bootloader
557 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)566 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
558- switch _, err := os.Stat(bootloaderPath); {567+ switch _, err := p.fs.Stat(bootloaderPath); {
559- case os.IsNotExist(err):568+ case p.fs.IsNotExist(err):
560 p.SendOutput(progressChan, "[ERROR] Bootloader missing")569 p.SendOutput(progressChan, "[ERROR] Bootloader missing")
561 verificationFailed = true570 verificationFailed = true
562 default:571 default:
@@ -565,7 +574,7 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
565 574
566 // Verify Limine config575 // Verify Limine config
567 limineConfPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)576 limineConfPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
568- switch content, err := os.ReadFile(limineConfPath); {577+ switch content, err := p.fs.ReadFile(limineConfPath); {
569 case err != nil:578 case err != nil:
570 p.SendOutput(progressChan, "[ERROR] Limine config missing")579 p.SendOutput(progressChan, "[ERROR] Limine config missing")
571 verificationFailed = true580 verificationFailed = true
@@ -580,7 +589,7 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
580 }589 }
581 590
582 // Verify NetworkManager is enabled591 // Verify NetworkManager is enabled
583- result := system.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")592+ result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")
584 switch {593 switch {
585 case result.ExitCode != 0:594 case result.ExitCode != 0:
586 p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled")595 p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled")
@@ -651,7 +660,7 @@ func (p *PostInstallPhase) CanSkip() bool {
651 func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error {660 func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error {
652 p.SendOutput(progressChan, "Configuring first-boot service...")661 p.SendOutput(progressChan, "Configuring first-boot service...")
653 662
654- switch err := os.MkdirAll(config.PathMntPostBoot, 0755); {663+ switch err := p.fs.MkdirAll(config.PathMntPostBoot, 0755); {
655 case err != nil:664 case err != nil:
656 return fmt.Errorf("failed to create post-boot directory: %w", err)665 return fmt.Errorf("failed to create post-boot directory: %w", err)
657 }666 }
@@ -659,7 +668,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
659 // Download logo.txt668 // Download logo.txt
660 p.SendOutput(progressChan, "Downloading logo.txt...")669 p.SendOutput(progressChan, "Downloading logo.txt...")
661 logoURL := fmt.Sprintf("%s/logo.txt", p.config.RawURL)670 logoURL := fmt.Sprintf("%s/logo.txt", p.config.RawURL)
662- resp, err := http.Get(logoURL)671+ resp, err := p.http.Get(logoURL)
663 switch {672 switch {
664 case err != nil:673 case err != nil:
665 return fmt.Errorf("failed to download logo.txt: %w", err)674 return fmt.Errorf("failed to download logo.txt: %w", err)
@@ -669,7 +678,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
669 }678 }
670 679
671 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")680 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
672- logoFile, err := os.Create(logoPath)681+ logoFile, err := p.fs.Create(logoPath)
673 switch {682 switch {
674 case err != nil:683 case err != nil:
675 resp.Body.Close()684 resp.Body.Close()
@@ -689,7 +698,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
689 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", script))698 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", script))
690 699
691 scriptURL := fmt.Sprintf("%s/install/post-boot/%s", p.config.RawURL, script)700 scriptURL := fmt.Sprintf("%s/install/post-boot/%s", p.config.RawURL, script)
692- resp, err := http.Get(scriptURL)701+ resp, err := p.http.Get(scriptURL)
693 switch {702 switch {
694 case err != nil:703 case err != nil:
695 return fmt.Errorf("failed to download %s: %w", script, err)704 return fmt.Errorf("failed to download %s: %w", script, err)
@@ -699,7 +708,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
699 }708 }
700 709
701 scriptPath := filepath.Join(config.PathMntPostBoot, script)710 scriptPath := filepath.Join(config.PathMntPostBoot, script)
702- scriptFile, err := os.Create(scriptPath)711+ scriptFile, err := p.fs.Create(scriptPath)
703 switch {712 switch {
704 case err != nil:713 case err != nil:
705 resp.Body.Close()714 resp.Body.Close()
@@ -715,7 +724,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
715 return fmt.Errorf("failed to save %s: %w", script, err)724 return fmt.Errorf("failed to save %s: %w", script, err)
716 }725 }
717 726
718- switch err := os.Chmod(scriptPath, 0755); {727+ switch err := p.fs.Chmod(scriptPath, 0755); {
719 case err != nil:728 case err != nil:
720 return fmt.Errorf("failed to set permissions on %s: %w", script, err)729 return fmt.Errorf("failed to set permissions on %s: %w", script, err)
721 }730 }
@@ -726,7 +735,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
726 // Download service template735 // Download service template
727 p.SendOutput(progressChan, "Creating systemd service...")736 p.SendOutput(progressChan, "Creating systemd service...")
728 serviceURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.PostBootServiceTemplate)737 serviceURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.PostBootServiceTemplate)
729- resp, err = http.Get(serviceURL)738+ resp, err = p.http.Get(serviceURL)
730 switch {739 switch {
731 case err != nil:740 case err != nil:
732 return fmt.Errorf("failed to download service template: %w", err)741 return fmt.Errorf("failed to download service template: %w", err)
@@ -747,7 +756,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
747 serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username)756 serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username)
748 757
749 servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)758 servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)
750- switch err := os.WriteFile(servicePath, []byte(serviceContent), 0644); {759+ switch err := p.fs.WriteFile(servicePath, []byte(serviceContent), 0644); {
751 case err != nil:760 case err != nil:
752 return fmt.Errorf("failed to write service file: %w", err)761 return fmt.Errorf("failed to write service file: %w", err)
753 }762 }
@@ -755,7 +764,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
755 p.SendOutput(progressChan, "[OK] Service file created")764 p.SendOutput(progressChan, "[OK] Service file created")
756 765
757 p.SendOutput(progressChan, "Enabling first-boot service...")766 p.SendOutput(progressChan, "Enabling first-boot service...")
758- switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); {767+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); {
759 case err != nil:768 case err != nil:
760 return fmt.Errorf("failed to enable service: %w", err)769 return fmt.Errorf("failed to enable service: %w", err)
761 }770 }
A internal/phases/postinstall_test.go +465 -0
@@ -0,0 +1,465 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
10+ "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
13+)
14+
15+// TestPostInstallPhasePreCheck tests post-install prerequisites
16+func TestPostInstallPhasePreCheck(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ setupMocks func(*mocks.MockFileSystem, *mocks.MockSystemExecutor)
20+ wantErr bool
21+ errContains string
22+ }{
23+ {
24+ name: "/mnt mounted and boot directory exists",
25+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
26+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27+ mockFS.EXPECT().Stat(config.PathMntBoot).Return(mockFileInfo{}, nil).Times(1)
28+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
29+ },
30+ wantErr: false,
31+ },
32+ {
33+ name: "/mnt not mounted",
34+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
35+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
36+ },
37+ wantErr: true,
38+ errContains: "is not mounted",
39+ },
40+ {
41+ name: "boot directory not found",
42+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
43+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
44+ mockFS.EXPECT().Stat(config.PathMntBoot).Return(nil, fmt.Errorf("not found")).Times(1)
45+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
46+ },
47+ wantErr: true,
48+ errContains: "boot directory not found",
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ ctrl := gomock.NewController(t)
55+ defer ctrl.Finish()
56+
57+ mockFS := mocks.NewMockFileSystem(ctrl)
58+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
59+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
60+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
61+
62+ tt.setupMocks(mockFS, mockSysExec)
63+
64+ tmpDir := t.TempDir()
65+ logPath := filepath.Join(tmpDir, "test.log")
66+ log, err := logger.New(logPath, false)
67+ if err != nil {
68+ t.Fatalf("Failed to create logger: %v", err)
69+ }
70+ defer log.Close()
71+
72+ cfg := config.NewConfig("test")
73+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
74+
75+ err = phase.PreCheck()
76+ if (err != nil) != tt.wantErr {
77+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
78+ return
79+ }
80+ if tt.wantErr && err != nil {
81+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
82+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// TestPostInstallPhaseTemplateReading tests template file reading
90+func TestPostInstallPhaseTemplateReading(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ templateFile string
94+ setupMocks func(*mocks.MockFileSystem)
95+ wantErr bool
96+ errContains string
97+ }{
98+ {
99+ name: "Successfully read bashrc template",
100+ templateFile: "bashrc",
101+ setupMocks: func(mockFS *mocks.MockFileSystem) {
102+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("bashrc content"), nil).Times(1)
103+ },
104+ wantErr: false,
105+ },
106+ {
107+ name: "Template file not found",
108+ templateFile: "missing.conf",
109+ setupMocks: func(mockFS *mocks.MockFileSystem) {
110+ mockFS.EXPECT().ReadFile(gomock.Any()).Return(nil, fmt.Errorf("file not found")).Times(1)
111+ },
112+ wantErr: true,
113+ errContains: "failed to read",
114+ },
115+ }
116+
117+ for _, tt := range tests {
118+ t.Run(tt.name, func(t *testing.T) {
119+ ctrl := gomock.NewController(t)
120+ defer ctrl.Finish()
121+
122+ mockFS := mocks.NewMockFileSystem(ctrl)
123+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
124+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
125+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
126+
127+ tt.setupMocks(mockFS)
128+
129+ tmpDir := t.TempDir()
130+ logPath := filepath.Join(tmpDir, "test.log")
131+ log, err := logger.New(logPath, false)
132+ if err != nil {
133+ t.Fatalf("Failed to create logger: %v", err)
134+ }
135+ defer log.Close()
136+
137+ cfg := config.NewConfig("test")
138+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
139+
140+ _, err = phase.readTemplate(tt.templateFile)
141+ if (err != nil) != tt.wantErr {
142+ t.Errorf("readTemplate() error = %v, wantErr %v", err, tt.wantErr)
143+ return
144+ }
145+ })
146+ }
147+}
148+
149+// TestPostInstallPhasePostCheck tests post-install validation
150+func TestPostInstallPhasePostCheck(t *testing.T) {
151+ ctrl := gomock.NewController(t)
152+ defer ctrl.Finish()
153+
154+ mockFS := mocks.NewMockFileSystem(ctrl)
155+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
156+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
157+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
158+
159+ tmpDir := t.TempDir()
160+ logPath := filepath.Join(tmpDir, "test.log")
161+ log, err := logger.New(logPath, false)
162+ if err != nil {
163+ t.Fatalf("Failed to create logger: %v", err)
164+ }
165+ defer log.Close()
166+
167+ cfg := config.NewConfig("test")
168+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
169+
170+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
171+
172+ err = phase.PostCheck()
173+ // PostCheck should succeed as it just saves the config
174+ if err != nil {
175+ t.Errorf("PostCheck() error = %v, want nil", err)
176+ }
177+}
178+
179+// TestPostInstallPhaseRollback tests post-install rollback
180+func TestPostInstallPhaseRollback(t *testing.T) {
181+ ctrl := gomock.NewController(t)
182+ defer ctrl.Finish()
183+
184+ mockFS := mocks.NewMockFileSystem(ctrl)
185+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
186+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
187+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
188+
189+ tmpDir := t.TempDir()
190+ logPath := filepath.Join(tmpDir, "test.log")
191+ log, err := logger.New(logPath, false)
192+ if err != nil {
193+ t.Fatalf("Failed to create logger: %v", err)
194+ }
195+ defer log.Close()
196+
197+ cfg := config.NewConfig("test")
198+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
199+
200+ // Rollback should always succeed (it's a cleanup operation)
201+ err = phase.Rollback()
202+ if err != nil {
203+ t.Errorf("Rollback() error = %v, want nil", err)
204+ }
205+}
206+
207+// TestPostInstallPhaseCanSkip tests the CanSkip method
208+func TestPostInstallPhaseCanSkip(t *testing.T) {
209+ ctrl := gomock.NewController(t)
210+ defer ctrl.Finish()
211+
212+ mockFS := mocks.NewMockFileSystem(ctrl)
213+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
214+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
215+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
216+
217+ tmpDir := t.TempDir()
218+ logPath := filepath.Join(tmpDir, "test.log")
219+ log, err := logger.New(logPath, false)
220+ if err != nil {
221+ t.Fatalf("Failed to create logger: %v", err)
222+ }
223+ defer log.Close()
224+
225+ cfg := config.NewConfig("test")
226+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
227+
228+ if phase.CanSkip() {
229+ t.Error("CanSkip() = true, want false")
230+ }
231+}
232+
233+// TestPostInstallPhaseExecute tests the Execute method
234+func TestPostInstallPhaseExecute(t *testing.T) {
235+ ctrl := gomock.NewController(t)
236+ defer ctrl.Finish()
237+
238+ mockFS := mocks.NewMockFileSystem(ctrl)
239+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
240+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
241+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
242+
243+ // Allow any file system and executor operations
244+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
245+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
246+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
247+ mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
248+ mockFS.EXPECT().Stat(gomock.Any()).Return(mockFileInfo{}, nil).AnyTimes()
249+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).AnyTimes()
250+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
251+ mockHTTP.EXPECT().Get(gomock.Any()).Return(nil, fmt.Errorf("skip http")).AnyTimes()
252+ mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
253+ mockChrExec.EXPECT().ChrootPacman(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
254+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
255+ mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any()).Return(system.CommandResult{ExitCode: 0}).AnyTimes()
256+
257+ tmpDir := t.TempDir()
258+ logPath := filepath.Join(tmpDir, "test.log")
259+ log, err := logger.New(logPath, false)
260+ if err != nil {
261+ t.Fatalf("Failed to create logger: %v", err)
262+ }
263+ defer log.Close()
264+
265+ cfg := config.NewConfig("test")
266+ cfg.KernelChoice = "linux"
267+ cfg.RawURL = "https://example.com"
268+
269+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
270+
271+ progressChan := make(chan ProgressUpdate, 100)
272+
273+ // Note: Full Execute testing is complex due to multiple async operations
274+ result := phase.Execute(progressChan)
275+
276+ // Verify result structure is valid
277+ _ = result // If we got here, Execute returned a valid result structure
278+}
279+
280+// TestPostInstallPhaseEncryptionHandling tests encryption-aware operations
281+func TestPostInstallPhaseEncryptionHandling(t *testing.T) {
282+ tests := []struct {
283+ name string
284+ encryptionType string
285+ expectedClosing bool
286+ }{
287+ {
288+ name: "Unencrypted - no closing needed",
289+ encryptionType: config.EncryptionNone,
290+ expectedClosing: false,
291+ },
292+ {
293+ name: "LUKS encrypted - closing needed",
294+ encryptionType: config.EncryptionLUKS,
295+ expectedClosing: true,
296+ },
297+ {
298+ name: "LUKS+LVM - closing needed",
299+ encryptionType: config.EncryptionLUKSLVM,
300+ expectedClosing: true,
301+ },
302+ }
303+
304+ for _, tt := range tests {
305+ t.Run(tt.name, func(t *testing.T) {
306+ ctrl := gomock.NewController(t)
307+ defer ctrl.Finish()
308+
309+ mockFS := mocks.NewMockFileSystem(ctrl)
310+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
311+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
312+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
313+
314+ tmpDir := t.TempDir()
315+ logPath := filepath.Join(tmpDir, "test.log")
316+ log, err := logger.New(logPath, false)
317+ if err != nil {
318+ t.Fatalf("Failed to create logger: %v", err)
319+ }
320+ defer log.Close()
321+
322+ cfg := config.NewConfig("test")
323+ cfg.EncryptionType = tt.encryptionType
324+
325+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
326+
327+ // Verify encryption type is stored correctly
328+ if phase.config.EncryptionType != tt.encryptionType {
329+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
330+ }
331+
332+ // Verify closing would be needed based on encryption
333+ needsClosing := tt.encryptionType == config.EncryptionLUKS || tt.encryptionType == config.EncryptionLUKSLVM
334+ if needsClosing != tt.expectedClosing {
335+ t.Errorf("Closing needed = %v, want %v", needsClosing, tt.expectedClosing)
336+ }
337+ })
338+ }
339+}
340+
341+// TestPostInstallPhaseFileSystemOperations tests file system interaction patterns
342+func TestPostInstallPhaseFileSystemOperations(t *testing.T) {
343+ tests := []struct {
344+ name string
345+ operation string // "read", "write", "mkdir", "create"
346+ }{
347+ {
348+ name: "Read pacman.conf",
349+ operation: "read",
350+ },
351+ {
352+ name: "Write pacman.conf",
353+ operation: "write",
354+ },
355+ {
356+ name: "Create hook directory",
357+ operation: "mkdir",
358+ },
359+ {
360+ name: "Create hook file",
361+ operation: "create",
362+ },
363+ }
364+
365+ for _, tt := range tests {
366+ t.Run(tt.name, func(t *testing.T) {
367+ ctrl := gomock.NewController(t)
368+ defer ctrl.Finish()
369+
370+ mockFS := mocks.NewMockFileSystem(ctrl)
371+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
372+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
373+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
374+
375+ // Setup basic expectations based on operation
376+ switch tt.operation {
377+ case "read":
378+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
379+ case "write":
380+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
381+ case "mkdir":
382+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
383+ case "create":
384+ mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
385+ }
386+
387+ tmpDir := t.TempDir()
388+ logPath := filepath.Join(tmpDir, "test.log")
389+ log, err := logger.New(logPath, false)
390+ if err != nil {
391+ t.Fatalf("Failed to create logger: %v", err)
392+ }
393+ defer log.Close()
394+
395+ cfg := config.NewConfig("test")
396+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
397+
398+ // Verify phase can be created
399+ if phase == nil {
400+ t.Error("PostInstallPhase is nil")
401+ }
402+ })
403+ }
404+}
405+
406+// TestPostInstallPhaseKernelVerification tests kernel verification logic
407+func TestPostInstallPhaseKernelVerification(t *testing.T) {
408+ tests := []struct {
409+ name string
410+ kernelChoice string
411+ expectedFormat string
412+ }{
413+ {
414+ name: "Linux kernel",
415+ kernelChoice: "linux",
416+ expectedFormat: "vmlinuz-linux",
417+ },
418+ {
419+ name: "Linux LTS kernel",
420+ kernelChoice: "linux-lts",
421+ expectedFormat: "vmlinuz-linux-lts",
422+ },
423+ {
424+ name: "Linux Zen kernel",
425+ kernelChoice: "linux-zen",
426+ expectedFormat: "vmlinuz-linux-zen",
427+ },
428+ }
429+
430+ for _, tt := range tests {
431+ t.Run(tt.name, func(t *testing.T) {
432+ ctrl := gomock.NewController(t)
433+ defer ctrl.Finish()
434+
435+ mockFS := mocks.NewMockFileSystem(ctrl)
436+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
437+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
438+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
439+
440+ tmpDir := t.TempDir()
441+ logPath := filepath.Join(tmpDir, "test.log")
442+ log, err := logger.New(logPath, false)
443+ if err != nil {
444+ t.Fatalf("Failed to create logger: %v", err)
445+ }
446+ defer log.Close()
447+
448+ cfg := config.NewConfig("test")
449+ cfg.KernelChoice = tt.kernelChoice
450+
451+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
452+
453+ // Verify kernel choice is stored
454+ if phase.config.KernelChoice != tt.kernelChoice {
455+ t.Errorf("Kernel choice = %q, want %q", phase.config.KernelChoice, tt.kernelChoice)
456+ }
457+
458+ // Verify expected kernel name format
459+ expectedKernelName := fmt.Sprintf("vmlinuz-%s", tt.kernelChoice)
460+ if !containsSubstring(expectedKernelName, "vmlinuz") {
461+ t.Errorf("Expected kernel name format %q doesn't contain 'vmlinuz'", expectedKernelName)
462+ }
463+ })
464+ }
465+}
M internal/phases/preflight.go +11 -8
@@ -2,30 +2,33 @@ package phases
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6- "os/exec"
7 "strings"5 "strings"
8 6
9 "github.com/bnema/archup/internal/config"7 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
10 "github.com/bnema/archup/internal/logger"9 "github.com/bnema/archup/internal/logger"
11 )10 )
12 11
13 // PreflightPhase handles system validation and initial configuration12 // PreflightPhase handles system validation and initial configuration
14 type PreflightPhase struct {13 type PreflightPhase struct {
15 *BasePhase14 *BasePhase
15+ fs interfaces.FileSystem
16+ cmdExec interfaces.CommandExecutor
16 }17 }
17 18
18 // NewPreflightPhase creates a new preflight phase19 // NewPreflightPhase creates a new preflight phase
19-func NewPreflightPhase(cfg *config.Config, log *logger.Logger) *PreflightPhase {20+func NewPreflightPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, cmdExec interfaces.CommandExecutor) *PreflightPhase {
20 return &PreflightPhase{21 return &PreflightPhase{
21 BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log),22 BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log),
23+ fs: fs,
24+ cmdExec: cmdExec,
22 }25 }
23 }26 }
24 27
25 // PreCheck validates system requirements28 // PreCheck validates system requirements
26 func (p *PreflightPhase) PreCheck() error {29 func (p *PreflightPhase) PreCheck() error {
27 // Check for Arch Linux30 // Check for Arch Linux
28- if _, err := os.Stat("/etc/arch-release"); os.IsNotExist(err) {31+ if _, err := p.fs.Stat("/etc/arch-release"); p.fs.IsNotExist(err) {
29 return fmt.Errorf("must be running on Arch Linux or Arch ISO")32 return fmt.Errorf("must be running on Arch Linux or Arch ISO")
30 }33 }
31 34
@@ -38,13 +41,13 @@ func (p *PreflightPhase) PreCheck() error {
38 }41 }
39 42
40 for marker, name := range derivatives {43 for marker, name := range derivatives {
41- if _, err := os.Stat(marker); err == nil {44+ if _, err := p.fs.Stat(marker); err == nil {
42 return fmt.Errorf("must be vanilla Arch (detected %s)", name)45 return fmt.Errorf("must be vanilla Arch (detected %s)", name)
43 }46 }
44 }47 }
45 48
46 // Check architecture49 // Check architecture
47- out, err := exec.Command("uname", "-m").Output()50+ out, err := p.cmdExec.Execute("uname", "-m")
48 arch := strings.TrimSpace(string(out))51 arch := strings.TrimSpace(string(out))
49 52
50 switch {53 switch {
@@ -55,12 +58,12 @@ func (p *PreflightPhase) PreCheck() error {
55 }58 }
56 59
57 // Check UEFI60 // Check UEFI
58- if _, err := os.Stat("/sys/firmware/efi/efivars"); os.IsNotExist(err) {61+ if _, err := p.fs.Stat("/sys/firmware/efi/efivars"); p.fs.IsNotExist(err) {
59 return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)")62 return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)")
60 }63 }
61 64
62 // Check Secure Boot65 // Check Secure Boot
63- out, err = exec.Command("bootctl", "status").Output()66+ out, err = p.cmdExec.Execute("bootctl", "status")
64 switch {67 switch {
65 case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):68 case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):
66 return fmt.Errorf("Secure Boot must be disabled")69 return fmt.Errorf("Secure Boot must be disabled")
M internal/phases/repos.go +37 -44
@@ -3,13 +3,12 @@ package phases
3 import (3 import (
4 "bufio"4 "bufio"
5 "fmt"5 "fmt"
6- "os"
7 "path/filepath"6 "path/filepath"
8 "strings"7 "strings"
9 8
10 "github.com/bnema/archup/internal/config"9 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
11 "github.com/bnema/archup/internal/logger"11 "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/system"
13 )12 )
14 13
15 // Pacman configuration constants14 // Pacman configuration constants
@@ -46,13 +45,19 @@ func IsValidAURHelper(helper string) bool {
46 // ReposPhase handles repository configuration45 // ReposPhase handles repository configuration
47 type ReposPhase struct {46 type ReposPhase struct {
48 *BasePhase47 *BasePhase
48+ fs interfaces.FileSystem
49+ sysExec interfaces.SystemExecutor
50+ chrExec interfaces.ChrootExecutor
49 chaoticConfig map[string]string51 chaoticConfig map[string]string
50 }52 }
51 53
52 // NewReposPhase creates a new repos phase54 // NewReposPhase creates a new repos phase
53-func NewReposPhase(cfg *config.Config, log *logger.Logger) *ReposPhase {55+func NewReposPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ReposPhase {
54 return &ReposPhase{56 return &ReposPhase{
55 BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log),57 BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log),
58+ fs: fs,
59+ sysExec: sysExec,
60+ chrExec: chrExec,
56 chaoticConfig: make(map[string]string),61 chaoticConfig: make(map[string]string),
57 }62 }
58 }63 }
@@ -66,15 +71,15 @@ func (p *ReposPhase) PreCheck() error {
66 }71 }
67 72
68 // Verify /mnt is mounted73 // Verify /mnt is mounted
69- result := system.RunSimple("mountpoint", "-q", config.PathMnt)74+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
70 switch {75 switch {
71 case result.ExitCode != 0:76 case result.ExitCode != 0:
72 return fmt.Errorf("%s is not mounted", config.PathMnt)77 return fmt.Errorf("%s is not mounted", config.PathMnt)
73 }78 }
74 79
75 // Verify pacman.conf exists80 // Verify pacman.conf exists
76- switch _, err := os.Stat(config.PathMntEtcPacmanConf); {81+ switch _, err := p.fs.Stat(config.PathMntEtcPacmanConf); {
77- case os.IsNotExist(err):82+ case p.fs.IsNotExist(err):
78 return fmt.Errorf("pacman.conf not found")83 return fmt.Errorf("pacman.conf not found")
79 }84 }
80 85
@@ -154,7 +159,7 @@ func (p *ReposPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
154 func (p *ReposPhase) syncDatabases(progressChan chan<- ProgressUpdate) error {159 func (p *ReposPhase) syncDatabases(progressChan chan<- ProgressUpdate) error {
155 p.SendOutput(progressChan, "Syncing package databases...")160 p.SendOutput(progressChan, "Syncing package databases...")
156 161
157- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {162+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {
158 case err != nil:163 case err != nil:
159 return fmt.Errorf("failed to sync databases: %w", err)164 return fmt.Errorf("failed to sync databases: %w", err)
160 }165 }
@@ -168,7 +173,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
168 p.SendOutput(progressChan, "Enabling multilib repository...")173 p.SendOutput(progressChan, "Enabling multilib repository...")
169 174
170 // Read pacman.conf175 // Read pacman.conf
171- content, err := os.ReadFile(config.PathMntEtcPacmanConf)176+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
172 switch {177 switch {
173 case err != nil:178 case err != nil:
174 return fmt.Errorf("failed to read pacman.conf: %w", err)179 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -212,7 +217,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
212 contentStr = strings.Join(lines, "\n")217 contentStr = strings.Join(lines, "\n")
213 218
214 // Write updated config219 // Write updated config
215- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {220+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
216 case err != nil:221 case err != nil:
217 return fmt.Errorf("failed to write pacman.conf: %w", err)222 return fmt.Errorf("failed to write pacman.conf: %w", err)
218 }223 }
@@ -226,7 +231,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
226 func (p *ReposPhase) loadChaoticConfig() error {231 func (p *ReposPhase) loadChaoticConfig() error {
227 configPath := p.getInstallPath(config.ChaoticConfigFile)232 configPath := p.getInstallPath(config.ChaoticConfigFile)
228 233
229- file, err := os.Open(configPath)234+ file, err := p.fs.Open(configPath)
230 switch {235 switch {
231 case err != nil:236 case err != nil:
232 return fmt.Errorf("failed to open chaotic config: %w", err)237 return fmt.Errorf("failed to open chaotic config: %w", err)
@@ -280,7 +285,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
280 285
281 // Import GPG key286 // Import GPG key
282 recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver)287 recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver)
283- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); {288+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); {
284 case err != nil:289 case err != nil:
285 p.logger.Warn("Failed to receive Chaotic GPG key, skipping Chaotic-AUR", "error", err)290 p.logger.Warn("Failed to receive Chaotic GPG key, skipping Chaotic-AUR", "error", err)
286 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key fetch failed)")291 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key fetch failed)")
@@ -289,7 +294,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
289 294
290 // Locally sign the key295 // Locally sign the key
291 lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID)296 lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID)
292- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); {297+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); {
293 case err != nil:298 case err != nil:
294 p.logger.Warn("Failed to sign Chaotic GPG key, skipping Chaotic-AUR", "error", err)299 p.logger.Warn("Failed to sign Chaotic GPG key, skipping Chaotic-AUR", "error", err)
295 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key signing failed)")300 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key signing failed)")
@@ -300,7 +305,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
300 305
301 // Download and install chaotic-keyring and chaotic-mirrorlist306 // Download and install chaotic-keyring and chaotic-mirrorlist
302 p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")307 p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
303- switch err := system.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, keyringURL, mirrorlistURL); {308+ switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, keyringURL, mirrorlistURL); {
304 case err != nil:309 case err != nil:
305 p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err)310 p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err)
306 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)")311 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)")
@@ -310,7 +315,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
310 p.SendOutput(progressChan, "[OK] Chaotic packages installed")315 p.SendOutput(progressChan, "[OK] Chaotic packages installed")
311 316
312 // Add Chaotic-AUR to pacman.conf317 // Add Chaotic-AUR to pacman.conf
313- content, err := os.ReadFile(config.PathMntEtcPacmanConf)318+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
314 switch {319 switch {
315 case err != nil:320 case err != nil:
316 return fmt.Errorf("failed to read pacman.conf: %w", err)321 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -327,7 +332,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
327 chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath)332 chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath)
328 contentStr += chaoticSection333 contentStr += chaoticSection
329 334
330- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {335+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
331 case err != nil:336 case err != nil:
332 return fmt.Errorf("failed to write pacman.conf: %w", err)337 return fmt.Errorf("failed to write pacman.conf: %w", err)
333 }338 }
@@ -336,14 +341,14 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
336 }341 }
337 342
338 // Update package databases343 // Update package databases
339- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {344+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {
340 case err != nil:345 case err != nil:
341 return fmt.Errorf("failed to update package databases: %w", err)346 return fmt.Errorf("failed to update package databases: %w", err)
342 }347 }
343 348
344 // Verify Chaotic-AUR is working349 // Verify Chaotic-AUR is working
345 verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName)350 verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName)
346- result := system.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)351+ result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
347 switch {352 switch {
348 case result.ExitCode != 0:353 case result.ExitCode != 0:
349 p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed")354 p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed")
@@ -362,7 +367,7 @@ func (p *ReposPhase) installPackagesIndividually(packages []string, progressChan
362 367
363 for _, pkg := range packages {368 for _, pkg := range packages {
364 individualCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", pkg)369 individualCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", pkg)
365- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); {370+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); {
366 case err != nil:371 case err != nil:
367 p.logger.Warn("Failed to install package", "package", pkg, "error", err)372 p.logger.Warn("Failed to install package", "package", pkg, "error", err)
368 failedPkgs = append(failedPkgs, pkg)373 failedPkgs = append(failedPkgs, pkg)
@@ -393,7 +398,7 @@ func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) er
393 398
394 // Try batch installation first399 // Try batch installation first
395 installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", strings.Join(packages, " "))400 installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", strings.Join(packages, " "))
396- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); {401+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); {
397 case err == nil:402 case err == nil:
398 p.logger.Info("Extra packages installed successfully", "count", len(packages))403 p.logger.Info("Extra packages installed successfully", "count", len(packages))
399 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages)))404 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages)))
@@ -429,7 +434,7 @@ func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) er
429 func (p *ReposPhase) loadExtraPackages() ([]string, error) {434 func (p *ReposPhase) loadExtraPackages() ([]string, error) {
430 packageFile := p.getInstallPath(config.ExtraPackagesFile)435 packageFile := p.getInstallPath(config.ExtraPackagesFile)
431 436
432- file, err := os.Open(packageFile)437+ file, err := p.fs.Open(packageFile)
433 switch {438 switch {
434 case err != nil:439 case err != nil:
435 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)440 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
@@ -468,43 +473,31 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error
468 // Ensure build dependencies are installed473 // Ensure build dependencies are installed
469 p.SendOutput(progressChan, "Verifying build dependencies...")474 p.SendOutput(progressChan, "Verifying build dependencies...")
470 depsCmd := "pacman -S --needed --noconfirm base-devel git"475 depsCmd := "pacman -S --needed --noconfirm base-devel git"
471- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, depsCmd); {476+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, depsCmd); {
472 case err != nil:477 case err != nil:
473 p.logger.Warn("Failed to install build dependencies", "error", err)478 p.logger.Warn("Failed to install build dependencies", "error", err)
474 // Don't fail, they might already be installed479 // Don't fail, they might already be installed
475 }480 }
476 481
477- // Build AUR helper from source482+ // Build AUR helper from source as configured user, then install as root
478 buildCmd := fmt.Sprintf(`483 buildCmd := fmt.Sprintf(`
479- su - %s -c '484+ set -e
480- set -e
481- cd /tmp || exit 1
482 485
483- # Clean up any previous build486+ # Build as user (clone, build, etc.)
484- rm -rf %s487+ su - %s -c 'cd /tmp && rm -rf %s && git clone https://aur.archlinux.org/%s.git && cd %s && makepkg -s --noconfirm' || exit 1
485 488
486- # Clone AUR repository489+ # Install built package as root
487- git clone https://aur.archlinux.org/%s.git || exit 1490+ pacman -U --noconfirm /tmp/%s/*.pkg.tar.zst || exit 1
488- cd %s || exit 1
489 491
490- # Build and install492+ # Clean up build directory
491- makepkg -si --noconfirm || exit 1493+ rm -rf /tmp/%s
492- '494+ `, p.config.Username, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper)
493- `, p.config.Username, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper)
494 495
495- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, buildCmd); {496+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, buildCmd); {
496 case err != nil:497 case err != nil:
497 return fmt.Errorf("failed to build %s: %w", p.config.AURHelper, err)498 return fmt.Errorf("failed to build %s: %w", p.config.AURHelper, err)
498 }499 }
499 500
500- // Cleanup build directory
501- cleanupCmd := fmt.Sprintf("rm -rf /tmp/%s", p.config.AURHelper)
502- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, cleanupCmd); {
503- case err != nil:
504- p.logger.Warn("Failed to cleanup AUR helper build directory", "error", err)
505- // Don't fail, just warn
506- }
507-
508 p.SendOutput(progressChan, fmt.Sprintf("[OK] %s built and installed successfully", p.config.AURHelper))501 p.SendOutput(progressChan, fmt.Sprintf("[OK] %s built and installed successfully", p.config.AURHelper))
509 502
510 return nil503 return nil
@@ -519,7 +512,7 @@ func (p *ReposPhase) getInstallPath(filename string) string {
519 // PostCheck validates repository configuration512 // PostCheck validates repository configuration
520 func (p *ReposPhase) PostCheck() error {513 func (p *ReposPhase) PostCheck() error {
521 // Verify pacman.conf was modified514 // Verify pacman.conf was modified
522- content, err := os.ReadFile(config.PathMntEtcPacmanConf)515+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
523 switch {516 switch {
524 case err != nil:517 case err != nil:
525 return fmt.Errorf("failed to read pacman.conf: %w", err)518 return fmt.Errorf("failed to read pacman.conf: %w", err)
M internal/phases/repos_test.go +12 -3
@@ -6,7 +6,10 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/archup/internal/config"8 "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
9 "github.com/bnema/archup/internal/logger"10 "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
10 )13 )
11 14
12 // TestEnableMultilibParsing tests the multilib section and Include line parsing15 // TestEnableMultilibParsing tests the multilib section and Include line parsing
@@ -536,12 +539,18 @@ func TestReposPhasePreCheckValidation(t *testing.T) {
536 mockSysExec := mocks.NewMockSystemExecutor(ctrl)539 mockSysExec := mocks.NewMockSystemExecutor(ctrl)
537 mockChrExec := mocks.NewMockChrootExecutor(ctrl)540 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
538 541
542+ // Set up expected mock calls based on whether we'll pass AUR helper validation
543+ // AUR helper validation happens first, so if invalid helper, we won't call RunSimple
544+ if !tt.expectError || (tt.expectError && tt.errorMsg != "invalid AUR helper") {
545+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
546+ mockFS.EXPECT().Stat(config.PathMntEtcPacmanConf).Return(mockFileInfo{}, nil).Times(1)
547+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
548+ }
549+
539 // Create ReposPhase550 // Create ReposPhase
540 phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec)551 phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
541 552
542- // Note: PreCheck also validates /mnt is mounted and pacman.conf exists553+ // Note: PreCheck validates AUR helper first, then /mnt is mounted, and pacman.conf exists
543- // In a real test environment, these would fail, so we're only testing
544- // the validation logic here by checking the error message
545 err := phase.PreCheck()554 err := phase.PreCheck()
546 555
547 if tt.expectError {556 if tt.expectError {
M internal/phases/systemconfig.go +29 -24
@@ -2,30 +2,35 @@ package phases
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6 "path/filepath"5 "path/filepath"
7 6
8 "github.com/bnema/archup/internal/config"7 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
9 "github.com/bnema/archup/internal/logger"9 "github.com/bnema/archup/internal/logger"
10- "github.com/bnema/archup/internal/system"
11 )10 )
12 11
13 // ConfigPhase handles system configuration12 // ConfigPhase handles system configuration
14 type ConfigPhase struct {13 type ConfigPhase struct {
15 *BasePhase14 *BasePhase
15+ fs interfaces.FileSystem
16+ sysExec interfaces.SystemExecutor
17+ chrExec interfaces.ChrootExecutor
16 }18 }
17 19
18 // NewConfigPhase creates a new config phase20 // NewConfigPhase creates a new config phase
19-func NewConfigPhase(cfg *config.Config, log *logger.Logger) *ConfigPhase {21+func NewConfigPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ConfigPhase {
20 return &ConfigPhase{22 return &ConfigPhase{
21 BasePhase: NewBasePhase("config", "System Configuration", cfg, log),23 BasePhase: NewBasePhase("config", "System Configuration", cfg, log),
24+ fs: fs,
25+ sysExec: sysExec,
26+ chrExec: chrExec,
22 }27 }
23 }28 }
24 29
25 // PreCheck validates configuration prerequisites30 // PreCheck validates configuration prerequisites
26 func (p *ConfigPhase) PreCheck() error {31 func (p *ConfigPhase) PreCheck() error {
27 // Verify /mnt is mounted32 // Verify /mnt is mounted
28- result := system.RunSimple("mountpoint", "-q", config.PathMnt)33+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
29 switch {34 switch {
30 case result.ExitCode != 0:35 case result.ExitCode != 0:
31 return fmt.Errorf("%s is not mounted", config.PathMnt)36 return fmt.Errorf("%s is not mounted", config.PathMnt)
@@ -97,13 +102,13 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
97 // Set timezone102 // Set timezone
98 timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone)103 timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone)
99 tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime)104 tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime)
100- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, tzCmd); {105+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, tzCmd); {
101 case err != nil:106 case err != nil:
102 return fmt.Errorf("failed to set timezone: %w", err)107 return fmt.Errorf("failed to set timezone: %w", err)
103 }108 }
104 109
105 // Set hardware clock110 // Set hardware clock
106- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "hwclock --systohc"); {111+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "hwclock --systohc"); {
107 case err != nil:112 case err != nil:
108 return fmt.Errorf("failed to set hardware clock: %w", err)113 return fmt.Errorf("failed to set hardware clock: %w", err)
109 }114 }
@@ -112,18 +117,18 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
112 117
113 // Set locale118 // Set locale
114 localeGen := p.config.Locale + " UTF-8\n"119 localeGen := p.config.Locale + " UTF-8\n"
115- switch err := os.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); {120+ switch err := p.fs.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); {
116 case err != nil:121 case err != nil:
117 return fmt.Errorf("failed to write locale.gen: %w", err)122 return fmt.Errorf("failed to write locale.gen: %w", err)
118 }123 }
119 124
120- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "locale-gen"); {125+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "locale-gen"); {
121 case err != nil:126 case err != nil:
122 return fmt.Errorf("failed to generate locale: %w", err)127 return fmt.Errorf("failed to generate locale: %w", err)
123 }128 }
124 129
125 localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale)130 localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale)
126- switch err := os.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); {131+ switch err := p.fs.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); {
127 case err != nil:132 case err != nil:
128 return fmt.Errorf("failed to write locale.conf: %w", err)133 return fmt.Errorf("failed to write locale.conf: %w", err)
129 }134 }
@@ -134,7 +139,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
134 switch {139 switch {
135 case p.config.Keymap != "":140 case p.config.Keymap != "":
136 vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap)141 vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap)
137- switch err := os.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); {142+ switch err := p.fs.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); {
138 case err != nil:143 case err != nil:
139 return fmt.Errorf("failed to write vconsole.conf: %w", err)144 return fmt.Errorf("failed to write vconsole.conf: %w", err)
140 }145 }
@@ -142,7 +147,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
142 }147 }
143 148
144 // Set hostname149 // Set hostname
145- switch err := os.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); {150+ switch err := p.fs.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); {
146 case err != nil:151 case err != nil:
147 return fmt.Errorf("failed to write hostname: %w", err)152 return fmt.Errorf("failed to write hostname: %w", err)
148 }153 }
@@ -153,7 +158,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
153 127.0.1.1 %s.localdomain %s158 127.0.1.1 %s.localdomain %s
154 `, p.config.Hostname, p.config.Hostname)159 `, p.config.Hostname, p.config.Hostname)
155 160
156- switch err := os.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); {161+ switch err := p.fs.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); {
157 case err != nil:162 case err != nil:
158 return fmt.Errorf("failed to write hosts file: %w", err)163 return fmt.Errorf("failed to write hosts file: %w", err)
159 }164 }
@@ -168,7 +173,7 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error
168 p.SendOutput(progressChan, "Configuring network...")173 p.SendOutput(progressChan, "Configuring network...")
169 174
170 // Enable NetworkManager service175 // Enable NetworkManager service
171- switch err := system.ChrootSystemctl(p.logger.LogPath(),config.PathMnt, "enable", config.ServiceNetworkManager); {176+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(),config.PathMnt, "enable", config.ServiceNetworkManager); {
172 case err != nil:177 case err != nil:
173 return fmt.Errorf("failed to enable NetworkManager: %w", err)178 return fmt.Errorf("failed to enable NetworkManager: %w", err)
174 }179 }
@@ -184,20 +189,20 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
184 189
185 // Create user with home directory190 // Create user with home directory
186 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)191 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
187- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {192+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
188 case err != nil:193 case err != nil:
189 return fmt.Errorf("failed to create user: %w", err)194 return fmt.Errorf("failed to create user: %w", err)
190 }195 }
191 196
192 // Set user password using stdin (secure - not visible in process list)197 // Set user password using stdin (secure - not visible in process list)
193 userPassInput := fmt.Sprintf("%s:%s", p.config.Username, p.config.UserPassword)198 userPassInput := fmt.Sprintf("%s:%s", p.config.Username, p.config.UserPassword)
194- switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); {199+ switch err := p.chrExec.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); {
195 case err != nil:200 case err != nil:
196 return fmt.Errorf("failed to set user password: %w", err)201 return fmt.Errorf("failed to set user password: %w", err)
197 }202 }
198 203
199 // Enable sudo for wheel group (passwordless for convenience)204 // Enable sudo for wheel group (passwordless for convenience)
200- switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {205+ switch err := p.fs.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
201 case err != nil:206 case err != nil:
202 return fmt.Errorf("failed to write sudoers config: %w", err)207 return fmt.Errorf("failed to write sudoers config: %w", err)
203 }208 }
@@ -214,14 +219,14 @@ func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
214 219
215 // Create zram-generator config220 // Create zram-generator config
216 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)221 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
217- switch err := os.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); {222+ switch err := p.fs.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); {
218 case err != nil:223 case err != nil:
219 return fmt.Errorf("failed to write zram-generator.conf: %w", err)224 return fmt.Errorf("failed to write zram-generator.conf: %w", err)
220 }225 }
221 226
222 // Create sysctl config for zram optimization227 // Create sysctl config for zram optimization
223 sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams)228 sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams)
224- switch err := os.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); {229+ switch err := p.fs.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); {
225 case err != nil:230 case err != nil:
226 return fmt.Errorf("failed to write sysctl zram config: %w", err)231 return fmt.Errorf("failed to write sysctl zram config: %w", err)
227 }232 }
@@ -234,21 +239,21 @@ func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
234 // PostCheck validates configuration239 // PostCheck validates configuration
235 func (p *ConfigPhase) PostCheck() error {240 func (p *ConfigPhase) PostCheck() error {
236 // Verify hostname was set241 // Verify hostname was set
237- switch _, err := os.Stat(config.PathMntEtcHostname); {242+ switch _, err := p.fs.Stat(config.PathMntEtcHostname); {
238- case os.IsNotExist(err):243+ case p.fs.IsNotExist(err):
239 return fmt.Errorf("hostname file was not created")244 return fmt.Errorf("hostname file was not created")
240 }245 }
241 246
242 // Verify locale was set247 // Verify locale was set
243- switch _, err := os.Stat(config.PathMntEtcLocaleConf); {248+ switch _, err := p.fs.Stat(config.PathMntEtcLocaleConf); {
244- case os.IsNotExist(err):249+ case p.fs.IsNotExist(err):
245 return fmt.Errorf("locale.conf was not created")250 return fmt.Errorf("locale.conf was not created")
246 }251 }
247 252
248 // Verify zram config exists253 // Verify zram config exists
249 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)254 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
250- switch _, err := os.Stat(zramConfigPath); {255+ switch _, err := p.fs.Stat(zramConfigPath); {
251- case os.IsNotExist(err):256+ case p.fs.IsNotExist(err):
252 return fmt.Errorf("zram-generator.conf was not created")257 return fmt.Errorf("zram-generator.conf was not created")
253 }258 }
254 259
A internal/ui/assets/assets.go +6 -0
@@ -0,0 +1,6 @@
1+package assets
2+
3+import _ "embed"
4+
5+//go:embed logo.txt
6+var LogoASCII string
A internal/ui/assets/logo.txt +6 -0
@@ -0,0 +1,6 @@
1+ ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
2+ ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
3+ ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
4+ ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
5+ ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
6+ ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░
D internal/ui/logo.go +0 -109
@@ -1,109 +0,0 @@
1-package ui
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/charmbracelet/lipgloss"
8- "github.com/bnema/archup/internal/ui/styles"
9-)
10-
11-const (
12- // Logo ASCII art (from logo.txt)
13- logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
14- ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
15- ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
16- ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
17- ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
18- ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░`
19-)
20-
21-// Logo represents the ArchUp logo display
22-type Logo struct {
23- width int
24- height int
25- version string
26-}
27-
28-// NewLogo creates a new Logo instance
29-// version should be injected via ldflags: -ldflags "-X main.version=v1.0.0"
30-func NewLogo(version string) *Logo {
31- lines := strings.Split(logoASCII, "\n")
32- width := 0
33- for _, line := range lines {
34- if len(line) > width {
35- width = len(line)
36- }
37- }
38-
39- if version == "" {
40- version = "dev"
41- }
42-
43- return &Logo{
44- width: width,
45- height: len(lines),
46- version: version,
47- }
48-}
49-
50-// Render renders the logo in full blue
51-func (l *Logo) Render() string {
52- lines := strings.Split(logoASCII, "\n")
53- var rendered strings.Builder
54-
55- for _, line := range lines {
56- if len(line) == 0 {
57- rendered.WriteString("\n")
58- continue
59- }
60-
61- // Render entire logo in blue
62- rendered.WriteString(styles.LogoCyanStyle.Render(line))
63- rendered.WriteString("\n")
64- }
65-
66- // Add version below logo
67- rendered.WriteString("\n")
68- versionLine := " " + l.version
69- rendered.WriteString(styles.LogoCyanStyle.Render(versionLine))
70- rendered.WriteString("\n")
71-
72- return rendered.String()
73-}
74-
75-// RenderCentered renders the logo centered using lipgloss alignment
76-func (l *Logo) RenderCentered(termWidth int) string {
77- return l.Render()
78-}
79-
80-// Width returns the logo width
81-func (l *Logo) Width() int {
82- return l.width
83-}
84-
85-// Height returns the logo height (including version line)
86-func (l *Logo) Height() int {
87- return l.height + 2 // +2 for blank line and version
88-}
89-
90-// ClearScreen clears the screen and displays the logo at the top
91-func ClearScreen(termWidth int, version string) string {
92- logo := NewLogo(version)
93- return fmt.Sprintf("\033[H\033[2J\n%s", logo.RenderCentered(termWidth))
94-}
95-
96-// RenderWithMessage renders the logo with a message below it
97-func RenderWithMessage(termWidth int, version, message string) string {
98- logo := NewLogo(version)
99- logoStr := logo.RenderCentered(termWidth)
100-
101- // Center the message
102- paddingLeft := (termWidth - lipgloss.Width(message)) / 2
103- if paddingLeft < 0 {
104- paddingLeft = 0
105- }
106- padding := strings.Repeat(" ", paddingLeft)
107-
108- return fmt.Sprintf("%s\n%s%s\n", logoStr, padding, message)
109-}
M internal/ui/styles/theme.go +3 -0
@@ -163,6 +163,9 @@ var (
163 LogoWhiteStyle = lipgloss.NewStyle().163 LogoWhiteStyle = lipgloss.NewStyle().
164 Foreground(ColorPureWhite)164 Foreground(ColorPureWhite)
165 165
166+ LogoBlueStyle = lipgloss.NewStyle().
167+ Foreground(ColorSkyBlue)
168+
166 // Progress/Spinner styles169 // Progress/Spinner styles
167 SpinnerStyle = lipgloss.NewStyle().170 SpinnerStyle = lipgloss.NewStyle().
168 Foreground(ColorBrightCyan)171 Foreground(ColorBrightCyan)
M internal/ui/views/helpers.go +20 -15
@@ -1,22 +1,15 @@
1 package views1 package views
2 2
3 import (3 import (
4- "fmt"
5 "strings"4 "strings"
6 5
6+ "github.com/bnema/archup/internal/ui/assets"
7 "github.com/bnema/archup/internal/ui/styles"7 "github.com/bnema/archup/internal/ui/styles"
8 "github.com/charmbracelet/lipgloss"8 "github.com/charmbracelet/lipgloss"
9 )9 )
10 10
11 const (11 const (
12 WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."12 WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."
13-
14- logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
15- ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
16- ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
17- ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
18- ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
19- ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░`
20 )13 )
21 14
22 // Logo represents the ArchUp logo display15 // Logo represents the ArchUp logo display
@@ -28,7 +21,7 @@ type Logo struct {
28 21
29 // NewLogo creates a new Logo instance22 // NewLogo creates a new Logo instance
30 func NewLogo(version string) *Logo {23 func NewLogo(version string) *Logo {
31- lines := strings.Split(logoASCII, "\n")24+ lines := strings.Split(assets.LogoASCII, "\n")
32 width := 025 width := 0
33 for _, line := range lines {26 for _, line := range lines {
34 if len(line) > width {27 if len(line) > width {
@@ -52,9 +45,9 @@ func (l *Logo) RenderCentered(termWidth int) string {
52 return l.Render()45 return l.Render()
53 }46 }
54 47
55-// Render renders the logo with theme colors48+// Render renders the logo with character-level styling
56 func (l *Logo) Render() string {49 func (l *Logo) Render() string {
57- lines := strings.Split(logoASCII, "\n")50+ lines := strings.Split(assets.LogoASCII, "\n")
58 var rendered strings.Builder51 var rendered strings.Builder
59 52
60 for _, line := range lines {53 for _, line := range lines {
@@ -63,14 +56,26 @@ func (l *Logo) Render() string {
63 continue56 continue
64 }57 }
65 58
66- // Apply bright cyan color to the logo59+ // Apply conditional styling based on character
67- rendered.WriteString(styles.LogoCyanStyle.Render(line))60+ var styledLine strings.Builder
61+ for _, ch := range line {
62+ if string(ch) == "░" {
63+ // Use darker blue for border character
64+ styledLine.WriteString(styles.LogoBlueStyle.Render(string(ch)))
65+ } else {
66+ // Use bright cyan for main logo
67+ styledLine.WriteString(styles.LogoCyanStyle.Render(string(ch)))
68+ }
69+ }
70+ rendered.WriteString(styledLine.String())
68 rendered.WriteString("\n")71 rendered.WriteString("\n")
69 }72 }
70 73
74+ // Add version below logo
75+ rendered.WriteString("\n")
76+ versionLine := " " + l.version
77+ rendered.WriteString(styles.HelpStyle.Render(versionLine))
71 rendered.WriteString("\n")78 rendered.WriteString("\n")
72- // Apply dimmed style to version
73- rendered.WriteString(styles.HelpStyle.Render(fmt.Sprintf(" %s\n", l.version)))
74 79
75 return rendered.String()80 return rendered.String()
76 }81 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close