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