// Package e2e — pengujian end-to-end binary tia-love yang sebenarnya. // // Binary hasil `go build` dijalankan di dalam pty, output-nya diumpankan ke // emulator layar mini (screen_test.go), lalu skenario mengirim tombol dan // memverifikasi isi LAYAR — bukan byte mentah. // // Jalankan: go test ./e2e/ -v -count=1 package e2e import ( "context" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "testing" "time" "github.com/creack/pty" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" ) const ( rows = 40 cols = 120 ) var binPath string func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "tia-e2e-*") if err != nil { panic(err) } binPath = filepath.Join(tmp, "tia-love") build := exec.Command("go", "build", "-o", binPath, "./cmd/tia-love") build.Dir = ".." // root modul (test dijalankan dari e2e/) if out, err := build.CombinedOutput(); err != nil { _ = os.RemoveAll(tmp) panic("build binary e2e gagal: " + err.Error() + "\n" + string(out)) } code := m.Run() _ = os.RemoveAll(tmp) os.Exit(code) } type tui struct { t *testing.T cmd *exec.Cmd ptmx *os.File mu sync.Mutex scr *screen waited bool } // startTUI memakai TIA_LANG=id eksplisit — deterministik walau mesin // pengembang/testing punya config.yaml sendiri. func startTUI(t *testing.T) *tui { return startTUIEnv(t, "TIA_LANG=id") } // envWith — gabung env proses + override tanpa duplikat key // (Linux getenv memakai kemunculan pertama, jadi duplikat berbahaya). func envWith(env []string) []string { if len(env) == 0 { return nil } overridden := map[string]bool{} for _, kv := range env { k, _, _ := strings.Cut(kv, "=") overridden[k+"="] = true } out := make([]string, 0, len(os.Environ())+len(env)) for _, kv := range os.Environ() { k, _, _ := strings.Cut(kv, "=") if overridden[k+"="] { continue } out = append(out, kv) } return append(out, env...) } func startTUIEnv(t *testing.T, env ...string) *tui { t.Helper() if runtime.GOOS == "windows" { t.Skip("e2e pty tidak didukung di Windows") } cmd := exec.Command(binPath) if e := envWith(env); len(e) > 0 { cmd.Env = e } ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols}) if err != nil { t.Fatalf("start pty: %v", err) } u := &tui{t: t, cmd: cmd, ptmx: ptmx, scr: newScreen(rows, cols)} go func() { buf := make([]byte, 32*1024) for { n, err := ptmx.Read(buf) if n > 0 { u.mu.Lock() u.scr.write(buf[:n]) u.mu.Unlock() } if err != nil { return } } }() t.Cleanup(func() { _ = ptmx.Close() if cmd.Process != nil { _ = cmd.Process.Kill() } if !u.waited { _, _ = cmd.Process.Wait() } }) return u } func (u *tui) send(s string) { u.t.Helper() if _, err := u.ptmx.Write([]byte(s)); err != nil { u.t.Fatalf("tulis ke pty: %v", err) } } func (u *tui) screen() string { u.mu.Lock() defer u.mu.Unlock() return u.scr.String() } func (u *tui) waitContains(substr string, d time.Duration) { u.t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { if strings.Contains(u.screen(), substr) { return } time.Sleep(50 * time.Millisecond) } u.t.Fatalf("layar tidak memuat %q dalam %v.\n--- snapshot layar ---\n%s", substr, d, u.screen()) } // waitHighlighted — menunggu baris yang memuat substr ber-latar (SGR bg), // yaitu penanda baris terpilih di daftar. func (u *tui) waitHighlighted(substr string, d time.Duration) { u.t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { u.mu.Lock() ok := u.scr.Highlighted(substr) u.mu.Unlock() if ok { return } time.Sleep(50 * time.Millisecond) } u.t.Fatalf("baris %q tidak ter-highlight dalam %v.\n--- snapshot layar ---\n%s", substr, d, u.screen()) } func (u *tui) waitExit(d time.Duration) int { u.t.Helper() done := make(chan error, 1) go func() { done <- u.cmd.Wait() }() select { case err := <-done: u.waited = true if err == nil { return 0 } if ee, ok := err.(*exec.ExitError); ok { return ee.ExitCode() } u.t.Fatalf("wait: %v", err) case <-time.After(d): u.t.Fatalf("proses tidak keluar dalam %v", d) } return -1 } // --- Skenario --- func TestVersionCommand(t *testing.T) { out, err := exec.Command(binPath, "version").CombinedOutput() if err != nil { t.Fatalf("`tia-love version` gagal: %v (%s)", err, out) } if !strings.Contains(string(out), "tia") { t.Fatalf("output version tak terduga: %q", out) } } func TestUnknownCommandExitCode(t *testing.T) { err := exec.Command(binPath, "perintah-hantu").Run() ee, ok := err.(*exec.ExitError) if !ok { t.Fatalf("mau ExitError, dapat %v", err) } if ee.ExitCode() != 2 { t.Fatalf("exit code %d, mau 2", ee.ExitCode()) } } func TestTUIBootAndQuit(t *testing.T) { u := startTUI(t) u.waitContains("tia — Terminal Is A My Way", 5*time.Second) u.waitContains("audio:", 5*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestToastAppears(t *testing.T) { u := startTUI(t) u.waitContains("tia — Terminal Is A My Way", 5*time.Second) u.send("t") u.waitContains("SUKSES", 3*time.Second) u.send("q") u.waitExit(5 * time.Second) } func TestReducedMotionToggle(t *testing.T) { u := startTUI(t) u.waitContains("tia — Terminal Is A My Way", 5*time.Second) u.send("a") // animasi dimatikan u.waitContains("animasi — fps", 3*time.Second) u.send("a") // nyalakan lagi — tidak boleh crash u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestDoctorCommand(t *testing.T) { out, err := exec.Command(binPath, "doctor").CombinedOutput() code := 0 if ee, ok := err.(*exec.ExitError); ok { code = ee.ExitCode() } if code != 0 && code != 1 { t.Fatalf("exit code %d, mau 0/1.\n%s", code, out) } s := string(out) for _, want := range []string{"docker", "audio", "go", "stacks"} { if !strings.Contains(s, want) { t.Fatalf("output doctor tidak memuat %q:\n%s", want, s) } } } func TestConfigFileRespected(t *testing.T) { xdg := t.TempDir() if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte("version: 1\nui:\n language: en\n"), 0o644); err != nil { t.Fatal(err) } u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg) u.waitContains("· home", 5*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestDoctorUsesConfigStacksDir(t *testing.T) { xdg := t.TempDir() if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil { t.Fatal(err) } stacks := filepath.Join(xdg, "stacks-custom") if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte("version: 1\nstacks_dir: "+stacks+"\n"), 0o644); err != nil { t.Fatal(err) } cmd := exec.Command(binPath, "doctor") cmd.Env = envWith([]string{"XDG_CONFIG_HOME=" + xdg}) out, _ := cmd.CombinedOutput() if !strings.Contains(string(out), "stacks-custom") { t.Fatalf("doctor tidak memakai stacks_dir dari config:\n%s", out) } } func TestResourceSections(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) for _, want := range [][2]string{ {"2", "Image"}, {"3", "Volume"}, {"4", "Jaringan"}, {"1", "Kontainer"}, } { u.send(want[0]) u.waitContains(want[1], 3*time.Second) } u.send("0") u.waitContains("beranda", 3*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestThemeCycle(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) u.send("T") // auto → dark, toast memberi umpan balik visual u.waitContains("Mode tema", 3*time.Second) u.send("T") // dark → light u.send("T") // light → terminal u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestEnglishLanguage(t *testing.T) { u := startTUIEnv(t, "TIA_LANG=en") u.waitContains("· home", 5*time.Second) u.waitContains("Clock", 5*time.Second) u.send("1") u.waitContains("· containers", 3*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestDashboardNavigation(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) u.waitContains("Jam", 5*time.Second) // panel jam blok u.waitContains("Docker", 5*time.Second) u.send("1") u.waitContains("Kontainer", 3*time.Second) u.send("0") u.waitContains("beranda", 3*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } func TestMouseAndNavigateKeys(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) u.send("1") // masuk view kontainer u.waitContains("Kontainer", 3*time.Second) u.send("j") u.send("k") u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik kiri di area sidebar u.send("r") // muat ulang u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } } // TestClickSelectsContainer — lokal/CI-with-docker saja: baris terpilih // ber-highlight (token Selection), klik berpindah seleksi, dan panel detail // mengikuti pilihan. func TestClickSelectsContainer(t *testing.T) { if _, err := os.Stat("/var/run/docker.sock"); err != nil { t.Skip("docker socket tidak ada") } cl, err := docker.New() if err != nil { t.Skipf("docker: %v", err) } defer cl.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() items, err := cl.ListContainers(ctx) if err != nil || len(items) < 2 { t.Skipf("butuh >= 2 kontainer (err=%v, n=%d)", err, len(items)) } short := func(s string) string { if len(s) > 12 { return s[:12] } return s } u := startTUI(t) u.waitContains("beranda", 5*time.Second) u.send("1") // masuk view kontainer u.waitContains(short(items[0].Name), 5*time.Second) u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) // klik baris item ke-2: xterm y=4 (1-based) → app row 3 → indeks 1 u.send("\x1b[<0;10;4M\x1b[<0;10;4m") u.waitHighlighted(short(items[1].Name), 3*time.Second) u.waitContains(items[1].ID, 3*time.Second) // panel detail ikut berpindah u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) } }