cat / 012
clone

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

main
012 / e2e / harness_test.go
harness_test.go 13.9K
1// Package e2e runs the real 012 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.6package e2e78import (9 "fmt"10 "os"11 "os/exec"12 "path/filepath"13 "strings"14 "sync"15 "testing"16 "time"1718 "github.com/creack/pty"19 ghostty "go.mitchellh.com/libghostty"20)2122var binPath string2324func TestMain(m *testing.M) {25 dir, err := os.MkdirTemp("", "012-e2e")26 if err != nil {27 panic(err)28 }29 binPath = filepath.Join(dir, "012")30 // fakekeyring swaps the OS credential store for a file under31 // XDG_CONFIG_HOME, which every session points at a temporary32 // directory; crashtest makes F12 panic (crash_test.go).33 build := exec.Command("go", "build", "-tags", "fakekeyring,crashtest", "-o", binPath, "./cmd/012")34 build.Dir = ".."35 build.Env = append(os.Environ(), "CGO_ENABLED=0")36 if out, err := build.CombinedOutput(); err != nil {37 fmt.Fprintf(os.Stderr, "building 012: %v\n%s", err, out)38 os.Exit(1)39 }40 addDocScreens()41 code := m.Run()42 os.RemoveAll(dir)43 os.Exit(code)44}4546const waitTimeout = 5 * time.Second4748// session is one running instance of 012 attached to a virtual terminal.49type session struct {50 t *testing.T51 dir string52 cmd *exec.Cmd53 pty *os.File5455 mu sync.Mutex // guards vt, enc, ev and raw56 raw []byte // everything the program wrote, for debugging57 vt *ghostty.Terminal58 enc *ghostty.KeyEncoder59 ev *ghostty.KeyEvent60 menc *ghostty.MouseEncoder61 mev *ghostty.MouseEvent62 cols uint1663 rows uint1664 // legacy, for options.legacyKeys, hides the keyboard protocol from65 // the terminal and sends keys as legacy terminals do: keys_test.go.66 legacy *keyFilter6768 exited chan struct{} // closed when the pty reader stops69 stdout *lockedBuffer // a piped session's standard output70 // peers are others' sessions of the same served file, for screens71 // of a shared workbook (screen.peers).72 peers []*session73 // agent is the agent attached to a session listening (live mode).74 agent *agent75}7677// options configures a session.78type options struct {79 dir string // working directory; a fresh temp dir if empty80 cols, rows uint16 // defaults to 100x3081 light bool // use the light reference palette82 env []string // extra environment, e.g. a fake JEV endpoint83 jev bool // answer JEV functions with a fake service (screens)84 // config is the session's config file. Every session has its own85 // config directory (XDG_CONFIG_HOME), so the user's is never read;86 // configDir shares one between sessions.87 config string88 configDir string89 // graphics turns on the kitty graphics protocol, so charts become90 // images; off by default, so screens show the text charts every91 // terminal gets.92 graphics bool93 // legacyKeys makes the terminal one without the kitty keyboard94 // protocol or modifyOtherKeys: keys are sent as legacy terminals95 // send them, and releases never (see keys_test.go).96 legacyKeys bool97 // program runs instead of 012, such as an ssh client reaching 01298 // serve.99 program string100 // startsOn is what the first screen shows once it's up: READY101 // unless set, such as a question asked at once.102 startsOn string103 // piped runs 012 with stdin as its standard input and its standard104 // output captured, as a stage of a pipeline: the terminal is its105 // controlling terminal and standard error only (pipe_test.go).106 piped bool107 stdin string108}109110// start launches 012 in dir (a fresh temp dir if empty) with args.111func start(t *testing.T, dir string, args ...string) *session {112 t.Helper()113 return startWith(t, options{dir: dir}, args...)114}115116func startWith(t *testing.T, o options, args ...string) *session {117 t.Helper()118 if o.dir == "" {119 o.dir = t.TempDir()120 }121 if o.cols == 0 {122 o.cols, o.rows = 100, 30123 }124 s := &session{t: t, dir: o.dir, cols: o.cols, rows: o.rows, exited: make(chan struct{})}125126 var err error127 if s.vt, err = ghostty.NewTerminal(ghostty.WithSize(s.cols, s.rows)); err != nil {128 t.Fatal(err)129 }130 applyPalette(t, s.vt, o.light)131 limit := uint64(0)132 if o.graphics {133 limit = 64 << 20134 if err := s.vt.Resize(s.cols, s.rows, cellW, cellH); err != nil {135 t.Fatal(err)136 }137 }138 if err := s.vt.SetKittyImageStorageLimit(&limit); err != nil {139 t.Fatal(err)140 }141 if s.enc, err = ghostty.NewKeyEncoder(); err != nil {142 t.Fatal(err)143 }144 if s.menc, err = ghostty.NewMouseEncoder(); err != nil {145 t.Fatal(err)146 }147 if s.mev, err = ghostty.NewMouseEvent(); err != nil {148 t.Fatal(err)149 }150 if s.ev, err = ghostty.NewKeyEvent(); err != nil {151 t.Fatal(err)152 }153154 program := binPath155 if o.program != "" {156 program = o.program157 }158 s.cmd = exec.Command(program, args...)159 s.cmd.Dir = o.dir160 // Tests never reach the real JEV service: the key is cleared unless a161 // test points JEV at a fake server through o.env.162 s.cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "TYPESAFE_API_KEY=", "TYPESAFE_BASE_URL=")163 s.cmd.Env = append(s.cmd.Env, configEnv(t, o)...)164 s.cmd.Env = append(s.cmd.Env, o.env...)165 if o.piped {166 s.pty, err = s.startPiped(o.stdin)167 } else {168 s.pty, err = pty.StartWithSize(s.cmd, &pty.Winsize{Cols: s.cols, Rows: s.rows})169 }170 if err != nil {171 t.Fatal(err)172 }173174 // Replies to the program's queries (device attributes, sizes, mode175 // reports) go back through the pty, as in a real terminal.176 s.vt.SetEffectWritePty(func(_ *ghostty.Terminal, data []byte) {177 s.pty.Write(append([]byte(nil), data...))178 })179 s.vt.SetEffectSize(func(*ghostty.Terminal) (ghostty.SizeReportSize, bool) {180 return ghostty.SizeReportSize{Columns: s.cols, Rows: s.rows, CellWidth: cellW, CellHeight: cellH}, true181 })182183 if o.legacyKeys {184 s.legacy = &keyFilter{}185 }186 legacy := s.legacy187 go func() {188 defer close(s.exited)189 buf := make([]byte, 32*1024)190 for {191 n, err := s.pty.Read(buf)192 if n > 0 {193 s.mu.Lock()194 s.vt.Write(legacy.filter(buf[:n]))195 s.raw = append(s.raw, buf[:n]...)196 s.mu.Unlock()197 }198 if err != nil {199 return200 }201 }202 }()203204 t.Cleanup(func() {205 s.cmd.Process.Kill()206 s.cmd.Wait()207 s.pty.Close()208 <-s.exited209 s.ev.Close()210 s.mev.Close()211 s.menc.Close()212 s.enc.Close()213 s.vt.Close()214 })215 if o.startsOn == "" {216 o.startsOn = "READY"217 }218 s.waitFor(o.startsOn)219 return s220}221222// screen returns the visible screen as plain text.223func (s *session) screen() string {224 return s.format(ghostty.FormatterFormatPlain)225}226227// html returns the visible screen as HTML (see renderHTML).228func (s *session) html() string {229 s.mu.Lock()230 defer s.mu.Unlock()231 out, err := renderHTML(s.vt)232 if err != nil {233 s.t.Fatal(err)234 }235 return out236}237238func (s *session) format(format ghostty.FormatterFormat) string {239 s.mu.Lock()240 defer s.mu.Unlock()241 f, err := ghostty.NewFormatter(s.vt,242 ghostty.WithFormatterFormat(format),243 ghostty.WithFormatterTrim(true))244 if err != nil {245 s.t.Fatal(err)246 }247 defer f.Close()248 out, err := f.FormatString()249 if err != nil {250 s.t.Fatal(err)251 }252 return out253}254255// line returns screen line i (0-based), trimmed on the right.256func (s *session) line(i int) string {257 lines := strings.Split(s.screen(), "\n")258 if i >= len(lines) {259 return ""260 }261 return strings.TrimRight(lines[i], " ")262}263264// eventually polls cond until it holds or the timeout expires.265func (s *session) eventually(what string, cond func() bool) {266 s.t.Helper()267 deadline := time.Now().Add(waitTimeout)268 for !cond() {269 if time.Now().After(deadline) {270 s.t.Fatalf("timed out waiting for %s; screen:\n%s", what, s.screen())271 }272 time.Sleep(10 * time.Millisecond)273 }274}275276func (s *session) waitFor(text string) {277 s.t.Helper()278 s.eventually(fmt.Sprintf("%q", text), func() bool { return strings.Contains(s.screen(), text) })279}280281func (s *session) waitForLine(i int, want string) {282 s.t.Helper()283 s.eventually(fmt.Sprintf("line %d == %q", i, want), func() bool { return s.line(i) == want })284}285286// waitExit waits for the program to end.287func (s *session) waitExit() {288 s.t.Helper()289 select {290 case <-s.exited:291 case <-time.After(waitTimeout):292 s.t.Fatalf("program did not exit; screen:\n%s", s.screen())293 }294}295296// keys sends key presses. Named keys are written in angle brackets, e.g.297// "<down>"; anything else is typed character by character.298func (s *session) keys(keys ...string) {299 s.t.Helper()300 for _, k := range keys {301 if name, ok := strings.CutPrefix(k, "<"); ok {302 s.namedKey(strings.TrimSuffix(name, ">"))303 } else {304 s.typeText(k)305 }306 }307}308309// modNames are the modifiers a named key can carry, as in "<ctrl+a>".310var modNames = map[string]ghostty.Mods{"shift": ghostty.ModShift, "ctrl": ghostty.ModCtrl, "alt": ghostty.ModAlt}311312// namedKey presses a key written as in "<shift+down>", without the313// brackets: modifiers, then a key name or a single character.314func (s *session) namedKey(name string) {315 s.t.Helper()316 parts := strings.Split(name, "+")317 var mods ghostty.Mods318 for _, p := range parts[:len(parts)-1] {319 mods |= modNames[p]320 }321 base := parts[len(parts)-1]322 if key, ok := namedKeys[base]; ok {323 var cp rune324 if base == "space" {325 cp = ' ' // the encoder needs the key's codepoint326 }327 s.press(key, mods, "", cp)328 return329 }330 if ck, ok := charKeys[rune(base[0])]; ok && len(base) == 1 {331 s.press(ck.key, mods|ck.mods, "", rune(base[0]))332 return333 }334 s.t.Fatalf("unknown key <%s>", name)335}336337// typeText types text character by character.338func (s *session) typeText(text string) {339 s.t.Helper()340 for _, r := range text {341 key, ok := charKeys[r]342 if !ok {343 s.t.Fatalf("no key mapping for %q", r)344 }345 unshifted := key.base346 if key.mods&ghostty.ModShift == 0 {347 unshifted = r348 }349 s.press(key.key, key.mods, string(r), unshifted)350 }351}352353func (s *session) press(key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {354 s.keyEvent(ghostty.KeyActionPress, key, mods, text, unshifted)355}356357// keyEvent sends a press, repeat or release of key, encoded for the358// modes the program turned on.359func (s *session) keyEvent(action ghostty.KeyAction, key ghostty.Key, mods ghostty.Mods, text string, unshifted rune) {360 if data, ok := legacyBytes(action, key, mods, unshifted); ok && s.legacy != nil {361 s.write(data)362 return363 }364 s.mu.Lock()365 s.enc.SetOptFromTerminal(s.vt)366 s.ev.SetAction(action)367 s.ev.SetKey(key)368 s.ev.SetMods(mods)369 // Shift that produced the text is consumed by the layout, as a real370 // terminal reports it; otherwise "+" would encode as shift+"=".371 var consumed ghostty.Mods372 if text != "" {373 consumed = mods & ghostty.ModShift374 }375 s.ev.SetConsumedMods(consumed)376 s.ev.SetUTF8(text)377 s.ev.SetUnshiftedCodepoint(unshifted)378 data, err := s.enc.Encode(s.ev)379 s.mu.Unlock()380 if err != nil {381 s.t.Fatalf("encoding key %v: %v", key, err)382 }383 s.write(data)384}385386// write sends a key's bytes.387func (s *session) write(data []byte) {388 if _, err := s.pty.Write(data); err != nil {389 s.t.Fatal(err)390 }391 // Give the program a moment so rapid keys aren't coalesced into one392 // read that it could misparse as a paste or an escape sequence.393 time.Sleep(5 * time.Millisecond)394}395396// Cell size in pixels, as reported to the program and used to place mouse397// events.398const cellW, cellH = 8, 16399400// mouse sends a mouse event at cell x, y, encoded by libghostty for the401// tracking mode and format the program turned on. MouseButtonUnknown means402// no button, e.g. plain motion.403func (s *session) mouse(action ghostty.MouseAction, button ghostty.MouseButton, x, y int, mods ghostty.Mods) {404 s.t.Helper()405 s.mu.Lock()406 s.menc.SetOptFromTerminal(s.vt)407 s.menc.SetOptSize(ghostty.MouseEncoderSize{408 ScreenWidth: uint32(s.cols) * cellW, ScreenHeight: uint32(s.rows) * cellH,409 CellWidth: cellW, CellHeight: cellH,410 })411 s.mev.SetAction(action)412 if button == ghostty.MouseButtonUnknown {413 s.mev.ClearButton()414 } else {415 s.mev.SetButton(button)416 }417 s.mev.SetMods(mods)418 // Motion with a button held is a drag.419 s.menc.SetOptAnyButtonPressed(action == ghostty.MouseActionMotion && button != ghostty.MouseButtonUnknown)420 s.mev.SetPosition(ghostty.MousePosition{X: float32(x*cellW + cellW/2), Y: float32(y*cellH + cellH/2)})421 data, err := s.menc.Encode(s.mev)422 s.mu.Unlock()423 if err != nil {424 s.t.Fatalf("encoding mouse event: %v", err)425 }426 if len(data) == 0 {427 s.t.Fatalf("mouse event at %d,%d not reported; is mouse tracking on?", x, y)428 }429 if _, err := s.pty.Write(data); err != nil {430 s.t.Fatal(err)431 }432 time.Sleep(5 * time.Millisecond)433}434435// click presses and releases a mouse button over cell x, y.436func (s *session) click(button ghostty.MouseButton, x, y int) {437 s.t.Helper()438 s.mouse(ghostty.MouseActionPress, button, x, y, 0)439 s.mouse(ghostty.MouseActionRelease, button, x, y, 0)440}441442// resize changes both the virtual terminal and the pty, which sends the443// program SIGWINCH.444func (s *session) resize(cols, rows uint16) {445 s.mu.Lock()446 s.cols, s.rows = cols, rows447 err := s.vt.Resize(cols, rows, 8, 16)448 s.mu.Unlock()449 if err != nil {450 s.t.Fatal(err)451 }452 if err := pty.Setsize(s.pty, &pty.Winsize{Cols: cols, Rows: rows}); err != nil {453 s.t.Fatal(err)454 }455}456457func (s *session) cursor() (x, y int) {458 s.mu.Lock()459 defer s.mu.Unlock()460 cx, _ := s.vt.CursorX()461 cy, _ := s.vt.CursorY()462 return int(cx), int(cy)463}464465func (s *session) title() string {466 s.mu.Lock()467 defer s.mu.Unlock()468 t, _ := s.vt.Title()469 return t470}471472func (s *session) activeScreen() ghostty.TerminalScreen {473 s.mu.Lock()474 defer s.mu.Unlock()475 scr, _ := s.vt.ActiveScreen()476 return scr477}478479// configEnv gives a session its own config directory, with o.config as480// its config file, and turns off the telemetry, theme and locale variables a481// developer's shell may set.482func configEnv(t *testing.T, o options) []string {483 t.Helper()484 dir := o.configDir485 if dir == "" {486 dir = filepath.Join(o.dir, ".config")487 }488 if o.config != "" {489 if err := os.MkdirAll(filepath.Join(dir, "012"), 0o700); err != nil {490 t.Fatal(err)491 }492 if err := os.WriteFile(filepath.Join(dir, "012", "config"), []byte(o.config), 0o600); err != nil {493 t.Fatal(err)494 }495 }496 return []string{"XDG_CONFIG_HOME=" + dir, "O12_THEME=", "TYPESAFE_DEFAULT_MODEL=", "O12_LOCALE=", "LC_ALL=", "LC_NUMERIC=", "LANG=C.UTF-8"}497}