From 3f3cc9e8bbc547cc7f0c7e5f67327ad790e0abc3 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:53:41 +0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20framework=20E2E=20binary=20asli=20?= =?UTF-8?q?(pty=20+=20emulator=20layar)=20=E2=80=94=206=20skenario=20hijau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/release.yml | 4 +- README.md | 13 ++ e2e/e2e_test.go | 206 +++++++++++++++++++++++++++ e2e/screen_test.go | 269 +++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 6 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 e2e/e2e_test.go create mode 100644 e2e/screen_test.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 318df33..935a7bf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -25,6 +25,6 @@ jobs: version: latest args: release --clean env: - # Set di repo Gitea → Settings → Actions → Secrets, - # scope token: write:repository + # Gitea Actions menyediakan GITEA_TOKEN otomatis untuk tiap run — + # tidak perlu secret manual (prefix GITEA_/GITHUB_ direservasi server). GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/README.md b/README.md index aab33b2..fea8df4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,19 @@ make test # unit test Nama binary final: **`tia-love`** (nama produk tetap **tia**). +## Test + +```bash +go test ./... # unit test + e2e +go test ./e2e/ -v # E2E saja (verbose) +``` + +E2E (`e2e/`) membangun binary asli, menjalankannya di dalam pty 120×40, dan +memverifikasi **isi layar** lewat emulator terminal mini (`e2e/screen_test.go`): +boot TUI → keluar bersih (exit 0), toast (`t`), reduced-motion (`a`), +klik mouse + navigasi (`j`/`k`), dan exit code error (2) untuk perintah asing. +Asersi berbasis layar (bukan byte mentah) agar tahan terhadap diff-renderer. + ## Instalasi Setelah rilis pertama tersedia: diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..3d84256 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,206 @@ +// 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 ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/creack/pty" +) + +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 +} + +func startTUI(t *testing.T) *tui { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("e2e pty tidak didukung di Windows") + } + cmd := exec.Command(binPath) + 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()) +} + +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 TestMouseAndNavigateKeys(t *testing.T) { + u := startTUI(t) + u.waitContains("belum ada klik", 5*time.Second) + // klik kiri di tengah layar (SGR 1006: ESC [ < 0 ; x ; y M) + u.send("\x1b[<0;60;10M\x1b[<0;60;10m") + u.waitContains("klik #1", 3*time.Second) + u.send("j") + u.send("k") + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} diff --git a/e2e/screen_test.go b/e2e/screen_test.go new file mode 100644 index 0000000..4f48567 --- /dev/null +++ b/e2e/screen_test.go @@ -0,0 +1,269 @@ +package e2e + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// screen — emulator terminal mini yang cukup untuk output Bubble Tea v2. +// +// Parsing escape: ESC → CSI (`ESC [ params final`) / OSC (`ESC ] … BEL|ESC\`) +// / two-char escape (ESC 7, ESC 8, ESC D, …). CSI yang diterapkan ke grid: +// CUP (H/f), relative moves (A-D), G/d, erase line (K), erase display (J), +// erase chars (X), save/restore cursor (ESC 7/8). SGR (m) & mode diam-diam +// diabaikan. +type screen struct { + rows, cols int + grid [][]rune + curR, curC int + savR, savC int + + st int // 0 normal · 1 esc · 2 esc-intern · 3 csi · 4 osc · 5 osc-esc + seq []byte + + pend []byte // byte UTF-8 yang belum lengkap +} + +const ( + stNormal = iota + stEsc + stEscInterm + stCSI + stOSC + stOSCEsc +) + +func newScreen(rows, cols int) *screen { + g := make([][]rune, rows) + for i := range g { + g[i] = make([]rune, cols) + for j := range g[i] { + g[i][j] = ' ' + } + } + return &screen{rows: rows, cols: cols, grid: g} +} + +func (s *screen) write(b []byte) { + for _, c := range b { + switch s.st { + case stNormal: + switch { + case c == 0x1b: + s.st = stEsc + case c == '\r': + s.curC = 0 + case c == '\n': + s.curR++ + s.curC = 0 + s.clampCursor() + case c == '\b': + if s.curC > 0 { + s.curC-- + } + case c < 0x20: + // kontrol lain diabaikan + case c < 0x80: + s.put(rune(c)) + default: + s.pend = append(s.pend, c) + r, size := utf8.DecodeRune(s.pend) + if r == utf8.RuneError && size <= 1 && len(s.pend) < utf8.UTFMax { + continue // tunggu byte UTF-8 berikutnya + } + s.put(r) + s.pend = s.pend[size:] + } + + case stEsc: + switch { + case c == '[': + s.st, s.seq = stCSI, s.seq[:0] + case c == ']': + s.st, s.seq = stOSC, s.seq[:0] + case c >= 0x20 && c <= 0x2f: + s.st, s.seq = stEscInterm, []byte{c} + default: + s.applySimple(rune(c)) + s.st = stNormal + } + + case stEscInterm: + if c >= 0x30 { // final byte (mis. ESC ( B) + s.applySimple(rune(c)) + s.st = stNormal + } else { + s.seq = append(s.seq, c) + } + + case stCSI: + if c >= 0x40 && c <= 0x7e { // final byte + s.applyCSI(rune(c), string(s.seq)) + s.st = stNormal + } else { + s.seq = append(s.seq, c) + } + + case stOSC: + if c == 0x07 { + s.st = stNormal + } else if c == 0x1b { + s.st = stOSCEsc + } + + case stOSCEsc: + if c == '\\' { + s.st = stNormal + } else { + s.st = stOSC + } + } + } +} + +func (s *screen) applySimple(c rune) { + switch c { + case '7': // save cursor + s.savR, s.savC = s.curR, s.curC + case '8': // restore cursor + s.curR, s.curC = s.savR, s.savC + case 'D': // index (turun 1 baris) + s.curR++ + s.clampCursor() + case 'M': // reverse index (naik 1 baris) + s.curR-- + s.clampCursor() + case 'E': // baris berikutnya, kolom 0 + s.curR++ + s.curC = 0 + s.clampCursor() + case 'c': // reset — layar bersih + s.clearAll() + } +} + +func (s *screen) applyCSI(final rune, params string) { + priv := byte(0) + if len(params) > 0 && (params[0] == '?' || params[0] == '>' || params[0] == '<' || params[0] == '=') { + priv = params[0] + params = params[1:] + } + if i := strings.IndexByte(params, '$'); i >= 0 { // "$p" dsb — abaikan + params = params[:i] + } + nums := parseNums(params) + + switch final { + case 'H', 'f': + s.curR = at(nums, 0, 1) - 1 + s.curC = at(nums, 1, 1) - 1 + s.clampCursor() + case 'A': + s.curR -= at(nums, 0, 1) + s.clampCursor() + case 'B': + s.curR += at(nums, 0, 1) + s.clampCursor() + case 'C': + s.curC += at(nums, 0, 1) + s.clampCursor() + case 'D': + s.curC -= at(nums, 0, 1) + s.clampCursor() + case 'G': + s.curC = at(nums, 0, 1) - 1 + s.clampCursor() + case 'd': + s.curR = at(nums, 0, 1) - 1 + s.clampCursor() + case 'J': + if at(nums, 0, 0) == 2 { + s.clearAll() + } + case 'K': + s.eraseLine(at(nums, 0, 0)) + case 'X': // erase n karakter (kursor tidak pindah) + n := at(nums, 0, 1) + for i := 0; i < n && s.curC+i < s.cols; i++ { + s.grid[s.curR][s.curC+i] = ' ' + } + case 'h': + if priv == '?' && at(nums, 0, 0) == 1049 { // masuk alt-screen + s.clearAll() + } + } +} + +func (s *screen) put(r rune) { + s.clampCursor() + s.grid[s.curR][s.curC] = r + s.curC++ +} + +func (s *screen) clampCursor() { + if s.curR < 0 { + s.curR = 0 + } else if s.curR >= s.rows { + s.curR = s.rows - 1 + } + if s.curC < 0 { + s.curC = 0 + } else if s.curC >= s.cols { + s.curC = s.cols - 1 + } +} + +func (s *screen) eraseLine(mode int) { + for c := range s.grid[s.curR] { + switch mode { + case 0: // dari kursor ke akhir + if c >= s.curC { + s.grid[s.curR][c] = ' ' + } + case 1: // dari awal ke kursor + if c <= s.curC { + s.grid[s.curR][c] = ' ' + } + case 2: // seluruh baris + s.grid[s.curR][c] = ' ' + } + } +} + +func (s *screen) clearAll() { + for i := range s.grid { + for j := range s.grid[i] { + s.grid[i][j] = ' ' + } + } +} + +// String — snapshot layar (spasi kanan dipangkas per baris). +func (s *screen) String() string { + lines := make([]string, s.rows) + for i, row := range s.grid { + lines[i] = strings.TrimRight(string(row), " ") + } + return strings.Join(lines, "\n") +} + +func parseNums(s string) []int { + if s == "" { + return nil + } + parts := strings.Split(s, ";") + nums := make([]int, 0, len(parts)) + for _, p := range parts { + n, _ := strconv.Atoi(p) + nums = append(nums, n) + } + return nums +} + +func at(nums []int, i, def int) int { + if i < len(nums) && nums[i] > 0 { + return nums[i] + } + return def +} diff --git a/go.mod b/go.mod index c600d53..b2cb838 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/harmonica v0.2.0 + github.com/creack/pty v1.1.24 github.com/ebitengine/oto/v3 v3.5.1 ) diff --git a/go.sum b/go.sum index ac57d7a..6d46b8c 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/ebitengine/oto/v3 v3.5.1 h1:7gL5DxxSQp8S1Me2jDSp+gSAyondYxpjM5RPBBqLT0c= github.com/ebitengine/oto/v3 v3.5.1/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o= github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=