815 lines
23 KiB
Go
815 lines
23 KiB
Go
// 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"
|
|
"fmt"
|
|
"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
|
|
|
|
// dumpFile — bila TIA_E2E_DUMP di-set, aliran pty mentah ditulis ke file ini
|
|
// (alat bedah saat emulator layar perlu dianalisis).
|
|
var dumpFile *os.File
|
|
|
|
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 p := os.Getenv("TIA_E2E_DUMP"); p != "" {
|
|
if f, err := os.Create(p); err == nil {
|
|
dumpFile = f
|
|
}
|
|
}
|
|
// Kunci kapabilitas terminal: tanpa ini, runner CI ber-TERM "dumb"
|
|
// membuat lipgloss menonaktifkan warna & latar → asersi highlight flaky.
|
|
base := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
|
|
if e := envWith(append(base, 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 dumpFile != nil {
|
|
_, _ = dumpFile.Write(buf[:n])
|
|
}
|
|
}
|
|
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{"engine", "audio", "go", "compose", "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{
|
|
{"1", "Stack"}, {"3", "Image"}, {"4", "Volume"}, {"5", "Jaringan"}, {"2", "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)
|
|
}
|
|
}
|
|
|
|
// TestStacksSection — stack dari disk tampil di seksi Stacks, tanpa docker:
|
|
// stacks_dir diarahkan ke folder sementara berisi satu stack.
|
|
func TestStacksSection(t *testing.T) {
|
|
xdg := t.TempDir()
|
|
stacksDir := filepath.Join(xdg, "stacks")
|
|
if err := os.MkdirAll(filepath.Join(stacksDir, "demo"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(stacksDir, "demo", "compose.yaml"),
|
|
[]byte("services:\n web:\n image: nginx\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := "version: 1\nstacks_dir: " + stacksDir + "\nui:\n language: id\n"
|
|
if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte(cfg), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("1")
|
|
u.waitContains("demo", 5*time.Second) // item stack dari disk
|
|
u.waitContains("terdeteksi", 3*time.Second) // status disk (compose tidak match)
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestStatsTab — tab Statistik: bar CPU/RAM, riwayat sparkline, NET/Disk,
|
|
// PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer.
|
|
func TestStatsTab(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("2")
|
|
u.waitContains(name, 5*time.Second)
|
|
u.send("l") // tab Statistik (fokus pindah ke konten)
|
|
u.waitContains("Riwayat CPU", 6*time.Second)
|
|
u.waitContains("PIDs", 3*time.Second)
|
|
u.send("h") // kembali ke Log
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestProcessTab — tab Proses: tabel `docker top` tampil.
|
|
func TestProcessTab(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("2")
|
|
u.waitContains(name, 5*time.Second)
|
|
u.send("h") // dari Log membungkus ke tab Proses (paling akhir)
|
|
u.waitContains("PID", 6*time.Second)
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestConfigEnvTabs — tab Konfigurasi (JSON inspect) & Env (KEY=value).
|
|
func TestConfigEnvTabs(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
env, err := cl.InspectEnv(ctx, items[0].ID)
|
|
if err != nil || len(env) == 0 {
|
|
t.Skipf("env kosong (err=%v)", err)
|
|
}
|
|
firstKey, _, _ := strings.Cut(env[0], "=")
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("2")
|
|
u.waitContains(name, 5*time.Second)
|
|
u.send("l") // Stats
|
|
u.send("l") // Konfigurasi
|
|
u.waitContains(`"Id"`, 6*time.Second)
|
|
u.send("l") // Env
|
|
u.waitContains(firstKey+"=", 6*time.Second)
|
|
u.send("k") // scroll dokumen tidak crash
|
|
u.send("G")
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestEventsActivity — event Docker nyata (volume create) muncul di panel
|
|
// Aktivitas beranda dalam beberapa detik. Butuh docker + CLI.
|
|
func TestEventsActivity(t *testing.T) {
|
|
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
|
t.Skip("docker socket tidak ada")
|
|
}
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
t.Skip("docker CLI tidak ada")
|
|
}
|
|
name := fmt.Sprintf("tiae2e%d", os.Getpid())
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
|
|
if out, err := exec.Command("docker", "volume", "create", name).CombinedOutput(); err != nil {
|
|
t.Skipf("volume create gagal: %v (%s)", err, out)
|
|
}
|
|
defer func() { _ = exec.Command("docker", "volume", "rm", name).Run() }()
|
|
|
|
u.waitContains(name, 10*time.Second) // baris aktivitas memuat nama volume
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// clickText — temukan substr di snapshot layar, kirim klik SGR ke tengahnya.
|
|
// Dipakai untuk membuktikan elemen UI mana pun bisa diklik.
|
|
func (u *tui) clickText(substr string) bool {
|
|
lines := strings.Split(u.screen(), "\n")
|
|
for y, line := range lines {
|
|
i := strings.Index(line, substr)
|
|
if i < 0 {
|
|
continue
|
|
}
|
|
x := len([]rune(line[:i])) + len([]rune(substr))/2
|
|
u.send(fmt.Sprintf("\x1b[<0;%d;%dM\x1b[<0;%d;%dm", x+1, y+1, x+1, y+1))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestMouseFooterHint — hint di status bar dapat diklik (klik [T] → ganti tema).
|
|
func TestMouseFooterHint(t *testing.T) {
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
if !u.clickText("[T]") {
|
|
t.Fatal("hint [T] tidak ditemukan di footer")
|
|
}
|
|
u.waitContains("Mode tema", 3*time.Second)
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestMouseTabBar — tab detail dapat diklik langsung (Statistik & Proses).
|
|
func TestMouseTabBar(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("2")
|
|
u.waitContains("[1-5]", 3*time.Second) // footer workspace = sudah pindah view
|
|
u.waitContains(name, 5*time.Second)
|
|
if !u.clickText("Statistik") {
|
|
t.Fatal("tab Statistik tidak ditemukan")
|
|
}
|
|
u.waitContains("[Statistik]", 3*time.Second) // tab aktif ditandai kurung
|
|
if !u.clickText("Proses") {
|
|
t.Fatal("tab Proses tidak ditemukan")
|
|
}
|
|
u.waitContains("PID", 5*time.Second)
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestMouseHomeContainer — klik baris kontainer di beranda → masuk workspace
|
|
// pada kontainer itu.
|
|
func TestMouseHomeContainer(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.waitContains(name, 5*time.Second) // tampil di panel Kontainer beranda
|
|
if !u.clickText(name) {
|
|
t.Fatal("baris kontainer tidak ditemukan")
|
|
}
|
|
u.waitContains("[1-5]", 3*time.Second) // footer workspace = bukti pindah view
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas.
|
|
func TestConvertCommand(t *testing.T) {
|
|
out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("convert gagal: %v (%s)", err, out)
|
|
}
|
|
s := string(out)
|
|
for _, want := range []string{"services:", "container_name: web", "80:80", "image: nginx"} {
|
|
if !strings.Contains(s, want) {
|
|
t.Fatalf("output convert tidak memuat %q:\n%s", want, s)
|
|
}
|
|
}
|
|
err = exec.Command(binPath, "convert", "bukan perintah docker").Run()
|
|
ee, ok := err.(*exec.ExitError)
|
|
if !ok || ee.ExitCode() != 1 {
|
|
t.Fatalf("perintah invalid harus exit 1, dapat %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStackDown — aksi stack via toolbar klikable: [D] → konfirmasi → [y]
|
|
// menjalankan `compose down`. Tanpa pull image: stack berisi service kosong.
|
|
func TestStackDown(t *testing.T) {
|
|
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
|
t.Skip("docker socket tidak ada")
|
|
}
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
t.Skip("docker CLI tidak ada")
|
|
}
|
|
xdg := t.TempDir()
|
|
stacksDir := filepath.Join(xdg, "stacks")
|
|
if err := os.MkdirAll(filepath.Join(stacksDir, "demo"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(stacksDir, "demo", "compose.yaml"),
|
|
[]byte("services: {}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := "version: 1\nstacks_dir: " + stacksDir + "\nui:\n language: id\n"
|
|
if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte(cfg), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("1")
|
|
u.waitContains("demo", 5*time.Second)
|
|
u.waitContains("[u]", 3*time.Second) // toolbar klikable tampil
|
|
|
|
// klik [D] → konfirmasi muncul; batalkan dengan keyboard (n)
|
|
if !u.clickText("[D]") {
|
|
t.Fatal("tombol [D] tidak ditemukan")
|
|
}
|
|
u.waitContains("Turunkan stack", 3*time.Second)
|
|
u.send("n")
|
|
u.waitContains("[u]", 3*time.Second) // kembali ke toolbar
|
|
|
|
// jalankan sungguhan: D → y → toast "selesai"
|
|
u.send("D")
|
|
u.waitContains("Turunkan stack", 3*time.Second)
|
|
u.send("y")
|
|
u.waitContains("selesai", 15*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("· stacks", 3*time.Second)
|
|
u.send("2")
|
|
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("Stack", 3*time.Second)
|
|
u.send("2")
|
|
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("2") // 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("2") // 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.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|
|
|
|
// TestLogTab — tab Log: stream nyata (timestamp/level), placeholder tab lain,
|
|
// fokus & scroll tidak crash. Butuh docker + minimal 1 kontainer.
|
|
func TestLogTab(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) == 0 {
|
|
t.Skipf("tidak ada kontainer (err=%v)", err)
|
|
}
|
|
name := items[0].Name
|
|
if len(name) > 12 {
|
|
name = name[:12]
|
|
}
|
|
|
|
u := startTUI(t)
|
|
u.waitContains("beranda", 5*time.Second)
|
|
u.send("2")
|
|
u.waitContains(name, 5*time.Second)
|
|
u.waitContains("Statistik", 3*time.Second) // baris tab
|
|
|
|
u.send("\t") // fokus ke panel konten
|
|
u.send("k")
|
|
u.send("j")
|
|
u.send("G")
|
|
u.send("l") // tab berikutnya (Statistik)
|
|
u.send("l") // Konfigurasi — kini data nyata: JSON inspect
|
|
u.waitContains(`"Id"`, 6*time.Second)
|
|
u.send("\x1b[<0;60;10M\x1b[<0;60;10m") // klik area konten = fokus
|
|
|
|
u.send("q")
|
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
|
t.Fatalf("exit code %d, mau 0", code)
|
|
}
|
|
}
|