test(e2e): framework E2E binary asli (pty + emulator layar) — 6 skenario hijau
CI / test (push) Successful in 10m50s
CI / test (push) Successful in 10m50s
This commit is contained in:
1 parent
0548de0db4
commit
3f3cc9e8bb
6 files changed
+493
-2
No files matched your search
@@ -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 }}
|
||||
@@ -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:
|
||||
|
||||
+206
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in new issue
Block a user