cat / 012
clone

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

main
012 / e2e / screens_test.go
screens_test.go 7.0K
1package e2e23import (4 "flag"5 "fmt"6 "html"7 "os"8 "path/filepath"9 "strings"10 "testing"11 "time"12)1314var update = flag.Bool("update", false, "rewrite golden screens in testdata/screens")1516// A screen is a named UI state captured as styled HTML. Goldens make every17// visual change show up in review; `make screens` renders them to a gallery18// with dark and light palettes for looking at.19type screen struct {20 name string21 opts options22 files func(t *testing.T, dir string) // fills the working directory first23 // ssh, when set, records the screen through 012 serve and a real24 // ssh client, with these words on the ssh command line; files fills25 // the served directory then.26 ssh []string27 // serve is 012 serve's flags, e.g. --share view.28 serve []string29 // user is the ssh user the screen is recorded as, and peers the30 // sessions of others that join the same served file first and act,31 // so the screen shows them.32 user string33 peers []peer34 args []string // 012's command line, e.g. --pipe35 // live starts 012 --listen on budget.012 and attaches the agent36 // called claude (s.agent) before setup: live mode's screens.37 live bool38 setup func(s *session)39}4041func TestScreens(t *testing.T) {42 dir := filepath.Join("testdata", "screens")43 if *update {44 os.MkdirAll(dir, 0o755)45 }46 for _, sc := range screens {47 t.Run(sc.name, func(t *testing.T) {48 t.Parallel()49 s := startScreen(t, sc)50 sc.setup(s)51 path := filepath.Join(dir, sc.name+".html")52 want, err := os.ReadFile(path)53 if err != nil && !*update {54 t.Fatalf("missing golden (run make screens): %v", err)55 }56 got := s.settledHTML(string(want))57 if *update {58 if err := os.WriteFile(path, []byte(got), 0o644); err != nil {59 t.Fatal(err)60 }61 return62 }63 if got != string(want) {64 actual := filepath.Join(t.TempDir(), sc.name+".html")65 os.WriteFile(actual, []byte(got), 0o644)66 t.Errorf("screen %s changed; if intended, run make screens and review the gallery.\n%s\nplain text now:\n%s", sc.name, firstDiff(string(want), got), s.screen())67 }68 })69 }70 if *update {71 if err := writeGallery(dir); err != nil {72 t.Fatal(err)73 }74 }75}7677// startScreen starts the session sc is recorded in: with a fake JEV78// service, a directory of files, or through 012 serve and ssh, as sc79// asks.80func startScreen(t *testing.T, sc screen) *session {81 opts := sc.opts82 if opts.jev {83 srv, _ := fakeTypeSafe(t)84 opts.env = []string{"TYPESAFE_API_KEY=test-key", "TYPESAFE_BASE_URL=" + srv.URL}85 }86 if sc.files != nil {87 opts.dir = t.TempDir()88 sc.files(t, opts.dir)89 }90 if sc.live {91 return startLiveScreen(t, opts)92 }93 args := sc.args94 if sc.ssh != nil {95 top, served := t.TempDir(), opts.dir96 if served == "" {97 served = t.TempDir()98 }99 opts.program, args = serveSSH(t, top, served, sc.serve...)100 opts.dir, args = top, append(args, sc.ssh...)101 var peers []*session102 for _, p := range sc.peers {103 peers = append(peers, p.join(t, opts, args))104 }105 if sc.user != "" {106 args = asUser(sc.user, args)107 }108 s := startWith(t, opts, args...)109 s.peers = peers110 return s111 }112 return startWith(t, opts, args...)113}114115// peer is someone else's session of a served file, for a screen that116// shows others: who, and the keys they press once in.117type peer struct {118 user string119 keys []string120 // sees is what their screen shows once they've acted.121 sees string122}123124// join starts the peer's session through ssh with args, and acts.125func (p peer) join(t *testing.T, opts options, args []string) *session {126 o := options{dir: opts.dir, program: opts.program, cols: 80, rows: 24}127 s := startWith(t, o, asUser(p.user, args)...)128 s.keys(p.keys...)129 if p.sees != "" {130 s.waitFor(p.sees)131 }132 return s133}134135// asUser is ssh's arguments args, logging in as user.136func asUser(user string, args []string) []string {137 return append([]string{"-l", user}, args...)138}139140// stableHTML waits until two captures in a row match, so a snapshot never141// catches a frame mid-render.142func (s *session) stableHTML() string {143 s.t.Helper()144 prev := s.html()145 for range 100 {146 time.Sleep(30 * time.Millisecond)147 cur := s.html()148 if cur == prev {149 return cur150 }151 prev = cur152 }153 s.t.Fatal("screen never settled")154 return ""155}156157// settledHTML is the screen once it has settled: stable, and showing158// want (the golden) if it comes to within waitTimeout. Some of a screen159// arrives after it first holds still, such as nu's highlighting, which160// a busy machine delays past a capture or two; a screen that differs161// from its golden for good fails, or is rewritten under -update, once162// the wait is over.163func (s *session) settledHTML(want string) string {164 s.t.Helper()165 got := s.stableHTML()166 for deadline := time.Now().Add(waitTimeout); got != want && time.Now().Before(deadline); {167 got = s.stableHTML()168 }169 return got170}171172// writeGallery renders all goldens into gallery.html (git-ignored), once173// with each reference palette.174func writeGallery(dir string) error {175 var b strings.Builder176 b.WriteString(`<!doctype html><meta charset="utf-8"><title>012 screens</title>177<style>178body{margin:0;padding:32px;background:#0f1012;color:#ddd;font:14px system-ui,sans-serif}179h1{font-weight:600;margin:0 0 24px}180section{margin:0 0 40px}181h2{font:500 13px ui-monospace,monospace;color:#aaa;margin:0 0 8px}182.term{display:inline-block;padding:14px 16px;border-radius:10px;box-shadow:0 8px 30px #0008}183.screen{margin:0;font:13px/1.2 "JetBrains Mono","SF Mono",Menlo,monospace;color:var(--fg)}184.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(820px,1fr));gap:8px 24px}185.wide{grid-column:1/-1}186`)187 for _, p := range []struct {188 class string189 ansi [16]uint32190 bg, fg string191 }{{"dark", darkANSI, "#1d1f21", "#c5c8c6"}, {"light", lightANSI, "#fafafa", "#1d1f21"}} {192 fmt.Fprintf(&b, ".%s{background:%s;color:%s;--bg:%s;--fg:%s}.%s{", p.class, p.bg, p.fg, p.bg, p.fg, p.class)193 for i, c := range p.ansi {194 fmt.Fprintf(&b, "--vt-palette-%d:#%06x;", i, c)195 }196 b.WriteString("}\n")197 }198 b.WriteString("</style><h1>012 screens</h1><div class=grid>\n")199 for _, sc := range screens {200 body, err := os.ReadFile(filepath.Join(dir, sc.name+".html"))201 if err != nil {202 return err203 }204 wide := ""205 if sc.opts.cols > 120 {206 wide = " class=wide" // spans the whole gallery row207 }208 fmt.Fprintf(&b, "<section id=%q%s><h2>%s</h2><div>", sc.name, wide, html.EscapeString(sc.name))209 th := "dark"210 if sc.opts.light {211 th = "light"212 }213 fmt.Fprintf(&b, "<div class=\"term %s\">%s</div>", th, body)214 b.WriteString("</div></section>\n")215 }216 b.WriteString("</div>\n")217 return os.WriteFile(filepath.Join(dir, "gallery.html"), []byte(b.String()), 0o644)218}219220// firstDiff shows the first line where a golden screen's HTML and the221// screen now part, which the plain text can't show when only a color or222// style changed.223func firstDiff(want, got string) string {224 w, g := strings.Split(want, "\n"), strings.Split(got, "\n")225 for i := 0; i < len(w) || i < len(g); i++ {226 var a, b string227 if i < len(w) {228 a = w[i]229 }230 if i < len(g) {231 b = g[i]232 }233 if a != b {234 return fmt.Sprintf("first difference, line %d:\n want %s\n got %s", i+1, a, b)235 }236 }237 return "no line differs"238}