cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add libghostty end-to-end test harness

Dami committed 841f543
Runs the real binary on a pty and renders it with libghostty-vt, with
keys encoded by libghostty's own encoder. The e2e tests live in their
own module so the main binary stays CGO_ENABLED=0.
commit
841f5437a470c9cd4742da55765f35430774da88
parent
ff1d0c7a5fdb3c9645cb81ff84e514d7c96f1581
author
Dami <dami.etoile@gmail.com>
5 files changed, +490 -0
AMakefile39++++
Ae2e/app_test.go120+++++++++++
Ae2e/go.mod8+
Ae2e/go.sum4+
Ae2e/harness_test.go319++++++++++++++++++++++++++++++
A Makefile +39 -0
A e2e/app_test.go +120 -0
@@ -0,0 +1,120 @@
1+package e2e2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ ghostty "go.mitchellh.com/libghostty"11+)12+13+// Screen layout: panel lines 0-2, column header 3, grid row 1 on line 4.14+const gridRow1 = 415+16+// numRow is how a grid row of numbers in default-width (9) columns looks:17+// a 6-wide row header, then each number right-aligned with one trailing18+// space, as 1-2-3's General format shows them.19+func numRow(row int, nums ...string) string {20+ line := fmt.Sprintf("%-6d", row)21+ for _, n := range nums {22+ line += fmt.Sprintf("%8s ", n)23+ }24+ return strings.TrimRight(line, " ")25+}26+27+func TestFormulaRecalc(t *testing.T) {28+ s := start(t, "")29+ s.keys("10", "<down>", "20", "<down>", "@SUM(A1..A2)", "<enter>")30+ s.waitFor("A3: @SUM(A1..A2)")31+ s.waitForLine(gridRow1+2, numRow(3, "30"))32+33+ // Changing a precedent recalculates the total on screen.34+ s.keys("<home>", "5", "<enter>")35+ s.waitForLine(gridRow1+2, numRow(3, "25"))36+}37+38+func TestPointModeBuildsFormula(t *testing.T) {39+ s := start(t, "")40+ s.keys("4", "<right>", "6", "<right>", "+", "<left>", "<left>")41+ s.waitFor("POINT")42+ s.waitForLine(1, "+A1")43+ s.keys("*", "<left>", "<enter>")44+ s.waitFor("C1: +A1*B1")45+ s.waitForLine(gridRow1, numRow(1, "4", "6", "24"))46+}47+48+func TestLabelsSpillAndAlign(t *testing.T) {49+ s := start(t, "")50+ s.keys("Quarterly revenue", "<down>", `"right`, "<down>", "^mid", "<enter>")51+ s.waitForLine(gridRow1, "1 Quarterly revenue")52+ s.waitForLine(gridRow1+1, "2 right")53+ s.waitForLine(gridRow1+2, "3 mid")54+}55+56+func TestInvalidFormulaCursor(t *testing.T) {57+ s := start(t, "")58+ s.keys("@SUM(A1", "<enter>")59+ s.waitFor("EDIT")60+ s.waitFor("expected , or )")61+ // The real terminal cursor sits on the edit line at the error.62+ s.eventually("cursor at end of edit line", func() bool {63+ x, y := s.cursor()64+ return x == len("@SUM(A1") && y == 165+ })66+}67+68+func TestMenuFirstLetterSetsWidth(t *testing.T) {69+ s := start(t, "")70+ s.keys("/")71+ s.waitFor("Worksheet Range File Quit")72+ s.keys("wcs", "20", "<enter>")73+ s.waitFor("A1: [W20]")74+}75+76+func TestSaveQuitReopen(t *testing.T) {77+ dir := t.TempDir()78+ s := start(t, dir)79+ s.keys("Budget", "<down>", "1200", "<down>", "+A2*12", "<enter>")80+ s.keys("/fs", "budget", "<enter>")81+ s.eventually("title update", func() bool { return s.title() == "one23 - budget.o23" })82+ if _, err := os.Stat(filepath.Join(dir, "budget.o23")); err != nil {83+ t.Fatal(err)84+ }85+86+ s.keys("/qy")87+ s.waitExit()88+ // Leaving the alternate screen restores the user's shell screen.89+ if scr := s.activeScreen(); scr != ghostty.ScreenPrimary {90+ t.Errorf("active screen after exit = %v, want primary", scr)91+ }92+93+ r := start(t, dir, "budget.o23")94+ r.waitForLine(gridRow1+2, numRow(3, "14400"))95+ if !strings.Contains(r.screen(), "budget.o23") {96+ t.Errorf("status line missing file name:\n%s", r.screen())97+ }98+}99+100+func TestResizeShowsMoreColumns(t *testing.T) {101+ s := start(t, "")102+ s.waitFor(" J")103+ if strings.Contains(s.line(3), " P") {104+ t.Fatalf("column P visible at 100 columns: %q", s.line(3))105+ }106+ s.resize(160, 30)107+ s.eventually("column P after resize", func() bool { return strings.Contains(s.line(3), " P") })108+}109+110+// Click handling is covered by the unit tests; this checks the program111+// actually turned on mouse reporting in the terminal.112+func TestMouseTrackingEnabled(t *testing.T) {113+ s := start(t, "")114+ s.eventually("mouse tracking enabled", func() bool {115+ s.mu.Lock()116+ defer s.mu.Unlock()117+ on, _ := s.vt.MouseTracking()118+ return on119+ })120+}
A e2e/go.mod +8 -0
A e2e/go.sum +4 -0
A e2e/harness_test.go +319 -0
@@ -0,0 +1,319 @@
1+// Package e2e runs the real one23 binary on a pseudo-terminal and renders2+// its output with libghostty-vt, Ghostty's terminal emulator core. Tests3+// assert on what a user would actually see: screen text, cursor position,4+// window title and screen mode. Keystrokes are encoded by libghostty from5+// the terminal's current modes, so they match what Ghostty would send.6+package e2e7+8+import (9+ "fmt"10+ "os"11+ "os/exec"12+ "path/filepath"13+ "strings"14+ "sync"15+ "testing"16+ "time"17+18+ "github.com/creack/pty"19+ ghostty "go.mitchellh.com/libghostty"20+)21+22+var binPath string23+24+func TestMain(m *testing.M) {25+ dir, err := os.MkdirTemp("", "one23-e2e")26+ if err != nil {27+ panic(err)28+ }29+ binPath = filepath.Join(dir, "one23")30+ build := exec.Command("go", "build", "-o", binPath, "./cmd/one23")31+ build.Dir = ".."32+ build.Env = append(os.Environ(), "CGO_ENABLED=0")33+ if out, err := build.CombinedOutput(); err != nil {34+ fmt.Fprintf(os.Stderr, "building one23: %v\n%s", err, out)35+ os.Exit(1)36+ }37+ code := m.Run()38+ os.RemoveAll(dir)39+ os.Exit(code)40+}41+42+const waitTimeout = 5 * time.Second43+44+// session is one running instance of one23 attached to a virtual terminal.45+type session struct {46+ t *testing.T47+ dir string48+ cmd *exec.Cmd49+ pty *os.File50+51+ mu sync.Mutex // guards vt, enc and ev52+ vt *ghostty.Terminal53+ enc *ghostty.KeyEncoder54+ ev *ghostty.KeyEvent55+ cols uint1656+ rows uint1657+58+ exited chan struct{} // closed when the pty reader stops59+}60+61+// start launches one23 in dir (a fresh temp dir if empty) with args.62+func start(t *testing.T, dir string, args ...string) *session {63+ t.Helper()64+ if dir == "" {65+ dir = t.TempDir()66+ }67+ s := &session{t: t, dir: dir, cols: 100, rows: 30, exited: make(chan struct{})}68+69+ var err error70+ if s.vt, err = ghostty.NewTerminal(ghostty.WithSize(s.cols, s.rows)); err != nil {71+ t.Fatal(err)72+ }73+ if s.enc, err = ghostty.NewKeyEncoder(); err != nil {74+ t.Fatal(err)75+ }76+ if s.ev, err = ghostty.NewKeyEvent(); err != nil {77+ t.Fatal(err)78+ }79+80+ s.cmd = exec.Command(binPath, args...)81+ s.cmd.Dir = dir82+ s.cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor")83+ s.pty, err = pty.StartWithSize(s.cmd, &pty.Winsize{Cols: s.cols, Rows: s.rows})84+ if err != nil {85+ t.Fatal(err)86+ }87+88+ // Replies to the program's queries (device attributes, sizes, mode89+ // reports) go back through the pty, as in a real terminal.90+ s.vt.SetEffectWritePty(func(_ *ghostty.Terminal, data []byte) {91+ s.pty.Write(append([]byte(nil), data...))92+ })93+ s.vt.SetEffectSize(func(*ghostty.Terminal) (ghostty.SizeReportSize, bool) {94+ return ghostty.SizeReportSize{Columns: s.cols, Rows: s.rows, CellWidth: 8, CellHeight: 16}, true95+ })96+97+ go func() {98+ defer close(s.exited)99+ buf := make([]byte, 32*1024)100+ for {101+ n, err := s.pty.Read(buf)102+ if n > 0 {103+ s.mu.Lock()104+ s.vt.Write(buf[:n])105+ s.mu.Unlock()106+ }107+ if err != nil {108+ return109+ }110+ }111+ }()112+113+ t.Cleanup(func() {114+ s.cmd.Process.Kill()115+ s.cmd.Wait()116+ s.pty.Close()117+ <-s.exited118+ s.ev.Close()119+ s.enc.Close()120+ s.vt.Close()121+ })122+ s.waitFor("READY")123+ return s124+}125+126+// screen returns the visible screen as plain text.127+func (s *session) screen() string {128+ s.mu.Lock()129+ defer s.mu.Unlock()130+ f, err := ghostty.NewFormatter(s.vt,131+ ghostty.WithFormatterFormat(ghostty.FormatterFormatPlain),132+ ghostty.WithFormatterTrim(true))133+ if err != nil {134+ s.t.Fatal(err)135+ }136+ defer f.Close()137+ out, err := f.FormatString()138+ if err != nil {139+ s.t.Fatal(err)140+ }141+ return out142+}143+144+// line returns screen line i (0-based), trimmed on the right.145+func (s *session) line(i int) string {146+ lines := strings.Split(s.screen(), "\n")147+ if i >= len(lines) {148+ return ""149+ }150+ return strings.TrimRight(lines[i], " ")151+}152+153+// eventually polls cond until it holds or the timeout expires.154+func (s *session) eventually(what string, cond func() bool) {155+ s.t.Helper()156+ deadline := time.Now().Add(waitTimeout)157+ for !cond() {158+ if time.Now().After(deadline) {159+ s.t.Fatalf("timed out waiting for %s; screen:\n%s", what, s.screen())160+ }161+ time.Sleep(10 * time.Millisecond)162+ }163+}164+165+func (s *session) waitFor(text string) {166+ s.t.Helper()167+ s.eventually(fmt.Sprintf("%q", text), func() bool { return strings.Contains(s.screen(), text) })168+}169+170+func (s *session) waitForLine(i int, want string) {171+ s.t.Helper()172+ s.eventually(fmt.Sprintf("line %d == %q", i, want), func() bool { return s.line(i) == want })173+}174+175+// waitExit waits for the program to end.176+func (s *session) waitExit() {177+ s.t.Helper()178+ select {179+ case <-s.exited:180+ case <-time.After(waitTimeout):181+ s.t.Fatalf("program did not exit; screen:\n%s", s.screen())182+ }183+}184+185+// keys sends key presses. Named keys are written in angle brackets, e.g.186+// "<down>"; anything else is typed character by character.187+func (s *session) keys(keys ...string) {188+ s.t.Helper()189+ for _, k := range keys {190+ if name, ok := strings.CutPrefix(k, "<"); ok {191+ key, ok := namedKeys[strings.TrimSuffix(name, ">")]192+ if !ok {193+ s.t.Fatalf("unknown key %s", k)194+ }195+ s.press(key, 0, "", 0)196+ continue197+ }198+ for _, r := range k {199+ key, ok := charKeys[r]200+ if !ok {201+ s.t.Fatalf("no key mapping for %q", r)202+ }203+ unshifted := key.base204+ if key.mods&ghostty.ModShift == 0 {205+ unshifted = r206+ }207+ s.press(key.key, key.mods, string(r), unshifted)208+ }209+ }210+}211+212+func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {213+ s.mu.Lock()214+ s.enc.SetOptFromTerminal(s.vt)215+ s.ev.SetAction(ghostty.KeyActionPress)216+ s.ev.SetKey(key)217+ s.ev.SetMods(mods)218+ // Shift that produced the text is consumed by the layout, as a real219+ // terminal reports it; otherwise "+" would encode as shift+"=".220+ var consumed ghostty.Mods221+ if text != "" {222+ consumed = mods & ghostty.ModShift223+ }224+ s.ev.SetConsumedMods(consumed)225+ s.ev.SetUTF8(text)226+ s.ev.SetUnshiftedCodepoint(unshifted)227+ data, err := s.enc.Encode(s.ev)228+ s.mu.Unlock()229+ if err != nil {230+ s.t.Fatalf("encoding key %v: %v", key, err)231+ }232+ if _, err := s.pty.Write(data); err != nil {233+ s.t.Fatal(err)234+ }235+ // Give the program a moment so rapid keys aren't coalesced into one236+ // read that it could misparse as a paste or an escape sequence.237+ time.Sleep(5 * time.Millisecond)238+}239+240+// resize changes both the virtual terminal and the pty, which sends the241+// program SIGWINCH.242+func (s *session) resize(cols, rows uint16) {243+ s.mu.Lock()244+ s.cols, s.rows = cols, rows245+ err := s.vt.Resize(cols, rows, 8, 16)246+ s.mu.Unlock()247+ if err != nil {248+ s.t.Fatal(err)249+ }250+ if err := pty.Setsize(s.pty, &pty.Winsize{Cols: cols, Rows: rows}); err != nil {251+ s.t.Fatal(err)252+ }253+}254+255+func (s *session) cursor() (x, y int) {256+ s.mu.Lock()257+ defer s.mu.Unlock()258+ cx, _ := s.vt.CursorX()259+ cy, _ := s.vt.CursorY()260+ return int(cx), int(cy)261+}262+263+func (s *session) title() string {264+ s.mu.Lock()265+ defer s.mu.Unlock()266+ t, _ := s.vt.Title()267+ return t268+}269+270+func (s *session) activeScreen() ghostty.TerminalScreen {271+ s.mu.Lock()272+ defer s.mu.Unlock()273+ scr, _ := s.vt.ActiveScreen()274+ return scr275+}276+277+var namedKeys = map[string]ghostty.Key{278+ "enter": ghostty.KeyEnter, "esc": ghostty.KeyEscape, "backspace": ghostty.KeyBackspace,279+ "tab": ghostty.KeyTab, "delete": ghostty.KeyDelete, "home": ghostty.KeyHome,280+ "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,281+ "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,282+ "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,283+ "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5,284+}285+286+type physKey struct {287+ key ghostty.Key288+ mods ghostty.Mods289+ base rune // unshifted character, for shifted symbols290+}291+292+// charKeys maps typed characters to physical keys on a US layout.293+var charKeys = func() map[rune]physKey {294+ m := map[rune]physKey{' ': {key: ghostty.KeySpace}}295+ for i := range 26 {296+ k := ghostty.KeyA + ghostty.Key(i)297+ m[rune('a'+i)] = physKey{key: k}298+ m[rune('A'+i)] = physKey{key: k, mods: ghostty.ModShift, base: rune('a' + i)}299+ }300+ digits := ")!@#$%^&*("301+ for i := range 10 {302+ k := ghostty.KeyDigit0 + ghostty.Key(i)303+ m[rune('0'+i)] = physKey{key: k}304+ m[rune(digits[i])] = physKey{key: k, mods: ghostty.ModShift, base: rune('0' + i)}305+ }306+ for _, p := range []struct {307+ plain, shifted rune308+ key ghostty.Key309+ }{310+ {'-', '_', ghostty.KeyMinus}, {'=', '+', ghostty.KeyEqual},311+ {',', '<', ghostty.KeyComma}, {'.', '>', ghostty.KeyPeriod},312+ {'/', '?', ghostty.KeySlash}, {';', ':', ghostty.KeySemicolon},313+ {'\'', '"', ghostty.KeyQuote},314+ } {315+ m[p.plain] = physKey{key: p.key}316+ m[p.shifted] = physKey{key: p.key, mods: ghostty.ModShift, base: p.plain}317+ }318+ return m319+}()