github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/rty/interactive_tester.go (about) 1 package rty 2 3 import ( 4 "encoding/gob" 5 "fmt" 6 "log" 7 "os" 8 "path/filepath" 9 "regexp" 10 "runtime/debug" 11 "strings" 12 "testing" 13 14 "github.com/pkg/errors" 15 "github.com/rivo/tview" 16 "github.com/stretchr/testify/assert" 17 18 "github.com/gdamore/tcell" 19 ) 20 21 const testDataDir = "testdata" 22 23 // Whitelist characters allowed in a name, because they will be used to create 24 // filenames. 25 // 26 // Forbid filenames with colons because they mess up the Windows git client :( 27 var validNameRegexp = regexp.MustCompile("^[a-zA-Z0-9 .,_-]+$") 28 29 type InteractiveTester struct { 30 usedNames map[string]bool 31 dummyScreen tcell.SimulationScreen 32 interactiveScreen tcell.Screen 33 rty RTY 34 t ErrorReporter 35 } 36 37 type ErrorReporter interface { 38 Errorf(format string, args ...interface{}) 39 Fatalf(format string, args ...interface{}) 40 Helper() 41 } 42 43 func NewInteractiveTester(t ErrorReporter, screen tcell.Screen) InteractiveTester { 44 dummyScreen := tcell.NewSimulationScreen("") 45 err := dummyScreen.Init() 46 assert.NoError(t, err) 47 48 return InteractiveTester{ 49 usedNames: make(map[string]bool), 50 dummyScreen: dummyScreen, 51 interactiveScreen: screen, 52 rty: NewRTY(dummyScreen, t), 53 t: t, 54 } 55 } 56 57 func (i *InteractiveTester) T() ErrorReporter { 58 return i.t 59 } 60 61 func (i *InteractiveTester) Run(name string, width int, height int, c Component) { 62 i.t.Helper() 63 64 err := i.runCaptureError(name, width, height, c) 65 if err != nil { 66 i.t.Errorf("error rendering %s: %v", name, err) 67 } 68 i.dummyScreen.Clear() 69 } 70 71 func (i *InteractiveTester) render(width int, height int, c Component) Canvas { 72 actual := newScreenCanvas(i.dummyScreen, i.t) 73 i.dummyScreen.SetSize(width, height) 74 defer func() { 75 if e := recover(); e != nil { 76 i.t.Fatalf("panic rendering: %v %s", e, debug.Stack()) 77 } 78 }() 79 i.rty.Render(c) 80 return actual 81 } 82 83 // Returns an error if rendering failed. 84 // If any other failure is encountered, fails via `i.t`'s `testing.T` and returns `nil`. 85 func (i *InteractiveTester) runCaptureError(name string, width int, height int, c Component) error { 86 i.t.Helper() 87 _, ok := i.usedNames[name] 88 if ok { 89 i.t.Fatalf("test name '%s' was already used", name) 90 } 91 92 if !validNameRegexp.MatchString(name) { 93 i.t.Fatalf("test name has invalid characters: %s", name) 94 } 95 96 actual := i.render(width, height, c) 97 expected := i.loadGoldenFile(name) 98 99 eq := canvasesEqual(actual, expected) 100 if !eq { 101 updated, err := i.displayAndMaybeWrite(name, actual, expected) 102 if err == nil { 103 if !updated { 104 err = errors.New("actual rendering didn't match expected") 105 } 106 } 107 if err != nil { 108 i.t.Errorf("%s: %v", name, err) 109 } 110 } 111 return nil 112 } 113 114 // Default windows terminal fonts typically don't include 115 // these characters, so we make substitutions. 116 var equivalentChars = [][]rune{ 117 {'▼', '↓'}, 118 {'▶', '→'}, 119 {'✖', '×'}, 120 {tview.BoxDrawingsLightDownAndRight, '⠋'}, 121 } 122 123 func canvasesEqual(actual, expected Canvas) bool { 124 actualWidth, actualHeight := actual.Size() 125 expectedWidth, expectedHeight := expected.Size() 126 if actualWidth != expectedWidth || actualHeight != expectedHeight { 127 return false 128 } 129 130 for x := 0; x < actualWidth; x++ { 131 for y := 0; y < actualHeight; y++ { 132 actualCh, _, actualStyle, _ := actual.GetContent(x, y) 133 expectedCh, _, expectedStyle, _ := expected.GetContent(x, y) 134 isEqualCh := actualCh == expectedCh 135 if !isEqualCh { 136 for _, pair := range equivalentChars { 137 if expectedCh == pair[0] { 138 expectedCh = pair[1] 139 } 140 if actualCh == pair[0] { 141 actualCh = pair[1] 142 143 } 144 145 } 146 isEqualCh = actualCh == expectedCh 147 } 148 if !isEqualCh || actualStyle != expectedStyle { 149 return false 150 } 151 } 152 } 153 154 return true 155 } 156 157 func (i *InteractiveTester) renderDiff(screen tcell.Screen, name string, actual, expected Canvas, highlightDiff bool) error { 158 screen.Clear() 159 160 actualWidth, actualHeight := actual.Size() 161 expectedWidth, expectedHeight := expected.Size() 162 163 curHeight := 0 164 165 printForTest(screen, curHeight, "y: accept, n: reject, d: diff, q: quit") 166 curHeight++ 167 168 printForTest(screen, curHeight, fmt.Sprintf("test: %s", name)) 169 curHeight++ 170 171 printForTest(screen, curHeight, "actual:") 172 curHeight++ 173 174 for y := 0; y < actualHeight; y++ { 175 for x := 0; x < actualWidth; x++ { 176 ch, _, style, _ := actual.GetContent(x, y) 177 if highlightDiff { 178 expectedCh, _, expectedStyle, _ := expected.GetContent(x, y) 179 if ch != expectedCh || style != expectedStyle { 180 style = style.Reverse(true) 181 } 182 } 183 184 screen.SetContent(x, curHeight, ch, nil, style) 185 } 186 curHeight++ 187 } 188 189 curHeight++ 190 191 printForTest(screen, curHeight, "expected:") 192 193 curHeight++ 194 195 for y := 0; y < expectedHeight; y++ { 196 for x := 0; x < expectedWidth; x++ { 197 ch, _, style, _ := expected.GetContent(x, y) 198 if highlightDiff { 199 actualCh, _, actualStyle, _ := actual.GetContent(x, y) 200 if ch != actualCh || style != actualStyle { 201 style = style.Reverse(true) 202 } 203 } 204 205 screen.SetContent(x, curHeight, ch, nil, style) 206 } 207 curHeight++ 208 } 209 210 screen.Show() 211 212 return nil 213 } 214 215 func (i *InteractiveTester) displayAndMaybeWrite(name string, actual, expected Canvas) (updated bool, err error) { 216 screen := i.interactiveScreen 217 if screen == nil { 218 return false, nil 219 } 220 221 highlightDiff := false 222 223 for { 224 err := i.renderDiff(screen, name, actual, expected, highlightDiff) 225 if err != nil { 226 return false, err 227 } 228 229 ev := screen.PollEvent() 230 if ev, ok := ev.(*tcell.EventKey); ok { 231 switch ev.Rune() { 232 case 'y': 233 return true, i.writeGoldenFile(name, actual) 234 case 'n': 235 return false, errors.New("user indicated expected output was not as desired") 236 case 'd': 237 highlightDiff = !highlightDiff 238 case 'q': 239 fmt.Println("User exited by pressing 'q'") 240 screen.Fini() 241 os.Exit(1) 242 } 243 } 244 } 245 } 246 247 func printForTest(screen tcell.Screen, y int, text string) { 248 for x, ch := range text { 249 screen.SetContent(x, y, ch, nil, tcell.StyleDefault) 250 } 251 } 252 253 type caseData struct { 254 Width int 255 Height int 256 Cells []caseCell 257 } 258 259 type caseCell struct { 260 Ch rune 261 Style tcell.Style 262 } 263 264 func (i *InteractiveTester) filename(name string) string { 265 return filepath.Join(testDataDir, strings.ReplaceAll(name, "/", "_")+".gob") 266 } 267 268 func (i *InteractiveTester) loadGoldenFile(name string) Canvas { 269 fi, err := os.Open(i.filename(name)) 270 if err != nil { 271 return newTempCanvas(1, 1, tcell.StyleDefault, i.t) 272 } 273 defer func() { 274 err := fi.Close() 275 if err != nil { 276 log.Printf("error closing file %s\n", fi.Name()) 277 } 278 }() 279 280 dec := gob.NewDecoder(fi) 281 var d caseData 282 err = dec.Decode(&d) 283 if err != nil { 284 return newTempCanvas(1, 1, tcell.StyleDefault, i.t) 285 } 286 287 c := newTempCanvas(d.Width, d.Height, tcell.StyleDefault, i.t) 288 for i, cell := range d.Cells { 289 x := i % d.Width 290 y := i / d.Width 291 c.SetContent(x, y, cell.Ch, nil, cell.Style) 292 } 293 294 return c 295 } 296 297 func (i *InteractiveTester) writeGoldenFile(name string, actual Canvas) error { 298 _, err := os.Stat(testDataDir) 299 if os.IsNotExist(err) { 300 err := os.Mkdir(testDataDir, os.FileMode(0755)) 301 if err != nil { 302 return err 303 } 304 } else if err != nil { 305 return err 306 } 307 fi, err := os.Create(i.filename(name)) 308 if err != nil { 309 return err 310 } 311 312 width, height := actual.Size() 313 d := caseData{ 314 Width: width, 315 Height: height, 316 } 317 318 // iterative over y first so we write by rows 319 for y := 0; y < height; y++ { 320 for x := 0; x < width; x++ { 321 ch, _, style, _ := actual.GetContent(x, y) 322 d.Cells = append(d.Cells, caseCell{Ch: ch, Style: style}) 323 } 324 } 325 326 enc := gob.NewEncoder(fi) 327 return enc.Encode(d) 328 } 329 330 // unfortunately, tcell misbehaves if we try to make a new Screen for every test 331 // this function is intended for use from a `TestMain`, so that we can have a global Screen across all tests in the package 332 func InitScreenAndRun(m *testing.M, screen *tcell.Screen) { 333 if s := os.Getenv("RTY_INTERACTIVE"); s != "" { 334 var err error 335 *screen, err = tcell.NewTerminfoScreen() 336 if err != nil { 337 log.Fatal(err) 338 } 339 err = (*screen).Init() 340 if err != nil { 341 log.Fatal(err) 342 } 343 } 344 345 r := m.Run() 346 if *screen != nil { 347 (*screen).Fini() 348 } 349 350 if r != 0 && *screen == nil { 351 log.Printf("To update golden files, run with env variable RTY_INTERACTIVE=1 and hit y/n on each case to overwrite (or not)") 352 } 353 os.Exit(r) 354 }