okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
2c536ce
main
1package conformance_test 2 3import ( 4"fmt" 5"slices" 6"strings" 7"testing" 8 9 uv"github.com/charmbracelet/ultraviolet" 10"github.com/charmbracelet/ultraviolet/internal/conformance" 11"github.com/charmbracelet/x/ansi" 12) 13 14// runner drives one fuzz program against one emulator. 15type runner struct { 16prog conformance.Program 17rend * uv.TerminalRenderer 18term oracle 19buf uv.ScreenBuffer 20 21// w and h track the live frame dimensions, which an OpResize changes. The 22// buffer and the draw rectangle follow them so a line drawn after a resize 23// is valid for the current frame, not the starting one. 24w ,h int 25 26// termW and termH are the terminal's own dimensions, which the screen 27// readback follows. A fullscreen frame is the terminal, so they match w and 28// h. An inline frame is shorter, and the rows below it belong to the 29// terminal rather than the frame: reading them back is what catches a 30// shrink that leaves the tail of a taller frame behind. 31termW ,termH int 32} 33 34// newRunner wires a renderer to a fresh emulator sized for the program. The 35// renderer's width model and the emulator's come from the same field, so the two 36// agree unless a test deliberately makes them disagree. 37func newRunner (t * testing.T ,p conformance.Program ,mk func (* testing.T ,int ,int ,bool )oracle )* runner { 38t .Helper () 39 40// An inline frame shares the terminal with whatever came before it, so the 41// terminal is taller than the frame and the extra rows are part of what is 42// under test. A fullscreen frame is the terminal. 43termH := p .Height 44if p .Inline { 45termH = conformance .InlineTermHeight 46 } 47 48term := mk (t ,p .Width ,termH ,p .GraphemeWidth ) 49 50// An inline frame shares the screen, so put something on it first and leave 51// the cursor below. Written straight to the emulator, since the renderer must 52// not know these rows exist, and kept inside the narrowest screen a program 53// can ask for: text that wraps takes more rows than it was given and puts the 54// frame below where frameTop says it is. 55if p .Inline { 56for y := range conformance .InlineRowsAbove { 57if _ ,err := fmt .Fprintf (term ,"%0*d\r\n" ,conformance .InlineSeedWidth ,y );err != nil { 58t .Fatalf ("seeding the rows above the frame: %v" ,err ) 59 } 60 } 61 } 62 63rend := uv .NewTerminalRenderer (term , []string { 64"TERM=xterm-256color" , 65"COLORTERM=truecolor" , 66 }) 67if p .Inline { 68rend .SetRelativeCursor (true ) 69// An inline renderer is told the terminal size, not the frame size; it 70// learns the frame from the buffer it is handed. Without this it has no 71// idea how much room it has below the frame. 72rend .Resize (p .Width ,termH ) 73 }else { 74rend .SetFullscreen (true ) 75 } 76rend .SetGraphemeWidth (p .GraphemeWidth ) 77rend .SetScrollOptim (p .ScrollOptim ) 78rend .Erase () 79 80// The buffer and the renderer must measure width the same way, otherwise 81// the cells the renderer is asked to draw have widths it did not expect. 82// The emulator follows the same flag, so all three agree. 83buf := uv .NewScreenBuffer (p .Width ,p .Height ) 84if p .GraphemeWidth { 85buf .Method = ansi .GraphemeWidth 86 } 87 88return & runner { 89prog :p , 90rend :rend , 91term :term , 92buf :buf , 93w :p .Width , 94h :p .Height , 95termW :p .Width , 96termH :termH , 97 } 98} 99 100func (r * runner )close () {r .term .Close () } 101 102func (r * runner )flush (t * testing.T ) { 103t .Helper () 104if err := r .rend .Flush ();err != nil { 105t .Fatalf ("Flush: %v" ,err ) 106 } 107} 108 109// step applies a single operation. 110func (r * runner )step (t * testing.T ,op conformance.Op ) { 111t .Helper () 112 113switch op .Kind { 114case conformance .OpDrawLine : 115uv .NewStyledString (op .Text ).Draw (r .buf ,uv .Rect (0 ,op .Y ,r .w ,1 )) 116case conformance .OpClear : 117r .buf .Clear () 118case conformance .OpRender : 119r .rend .Render (r .buf .RenderBuffer ) 120r .flush (t ) 121case conformance .OpRedraw : 122r .rend .Redraw (r .buf .RenderBuffer ) 123r .flush (t ) 124case conformance .OpMoveTo : 125r .rend .MoveTo (op .N ,0 ) 126r .flush (t ) 127case conformance .OpResize : 128// Resize the buffer, the emulator, and the renderer's tab stops in 129// lockstep, the way a real SIGWINCH handler would. The renderer's model 130// of the previous frame keeps its old dimensions, which is exactly the 131// stale-geometry case under test. 132// 133// An inline frame changes height on its own, without the terminal 134// moving underneath it: a view swaps for a taller or shorter one and 135// the terminal never hears about it. So the terminal keeps its height 136// here and only the frame changes, which also means a resize that keeps 137// the width is a pure change of frame shape. 138r .w ,r .h = op .W ,op .H 139r .termW = op .W 140if ! r .prog .Inline { 141r .termH = op .H 142 } 143r .buf .Resize (op .W ,op .H ) 144r .term .Resize (r .termW ,r .termH ) 145r .rend .Resize (r .termW ,r .termH ) 146case conformance .OpErase : 147r .rend .Erase () 148 } 149} 150 151// frameTop is the terminal row the frame's first row sits on. A fullscreen frame 152// starts at the top of the screen; an inline one starts below the rows that were 153// already there, which is where the cursor was when the renderer first saw it. 154// 155// Anything comparing a frame row against a screen row has to go through this. 156// The differential targets do not, because both of their runs are offset 157// identically and the offset cancels. 158func (r * runner )frameTop ()int { 159if r .prog .Inline { 160return conformance .InlineRowsAbove 161 } 162return 0 163} 164 165// screen returns every row of the terminal, for comparison against another 166// runner. Every row, not every row of the frame: an inline frame that shrinks 167// has to clear what it no longer covers, and a row it abandoned is exactly 168// where the residue shows up. 169func (r * runner )screen (t * testing.T ) []string { 170t .Helper () 171 172rows := make ([]string ,r .termH ) 173for y := range rows { 174rows [y ]= r .term .Row (t ,y ) 175 } 176return rows 177} 178 179// runIncremental replays the whole program through one renderer, so its model 180// accumulates history exactly as it would in a running application. 181func runIncremental (t * testing.T ,p conformance.Program ,mk func (* testing.T ,int ,int ,bool )oracle ) []string { 182t .Helper () 183 184r := newRunner (t ,p ,mk ) 185defer r .close () 186 187for _ ,op := range p .Ops { 188r .step (t ,op ) 189 } 190 191// Push the final state out even if the program did not end with a render, 192// otherwise trailing drawing ops would be dropped and the comparison would 193// run against a stale screen. 194r .rend .Render (r .buf .RenderBuffer ) 195r .flush (t ) 196 197return r .screen (t ) 198} 199 200// runFullRepaint builds the same final buffer but paints it once onto a fresh 201// terminal with no prior history to diff against. That makes its output an 202// unconditional full paint, which is the ground truth the incremental path has 203// to match. 204func runFullRepaint (t * testing.T ,p conformance.Program ,mk func (* testing.T ,int ,int ,bool )oracle ) []string { 205t .Helper () 206 207r := newRunner (t ,p ,mk ) 208defer r .close () 209 210// Replay only the operations that shape the buffer. Skipping the renders is 211// what makes this a reference: the buffer ends up identical, but the 212// renderer never sees an intermediate frame and so cannot drift. Resizes are 213// replayed too, so the final buffer has the same dimensions as the 214// incremental run. 215for _ ,op := range p .Ops { 216switch op .Kind { 217case conformance .OpDrawLine ,conformance .OpClear ,conformance .OpResize : 218r .step (t ,op ) 219 } 220 } 221 222r .rend .Render (r .buf .RenderBuffer ) 223r .flush (t ) 224 225return r .screen (t ) 226} 227 228// compare reports the first differing row, with the program that produced it. 229// Printing the program is what makes a fuzz failure actionable: the raw input is 230// a byte string that says nothing about what the renderer was asked to do. 231func compare (t * testing.T ,p conformance.Program ,what string ,got ,want []string ) { 232t .Helper () 233 234for y := range want { 235if got [y ]== want [y ] { 236continue 237 } 238t .Errorf ("%s: screen disagrees with a full repaint at row %d\n" + 239" got %q\n" + 240" want %q\n" + 241"program:\n%s" , 242what ,y ,got [y ],want [y ],p ) 243return 244 } 245} 246 247// Codepoints an emulator is known to swallow, so [FuzzScreenShowsContent] can 248// assert on everything else instead of giving up on whole clusters. Each entry 249// is a characterised emulator behaviour, not a renderer bug: keep them as tight 250// as the emulator forces and no tighter, since every rune listed here is one the 251// target can no longer catch the renderer dropping. 252const ( 253// VS16 asks for emoji presentation. ghostty's legacy width mode reads it as 254// a hint and does not store it in the cell, so it never reads back. 255variationSelector16 = '\ufe0f' 256// The keycap combining mark. Listed for documentation; x/vt's trouble with 257// keycaps is handled per cluster rather than per codepoint, see skip below. 258combiningKeycap = '\u20e3' 259) 260 261// keycap is the cluster x/vt cannot place coherently. It splits the cluster 262// across two cells but advances the cursor by only one, so its own idea of 263// where the row ends disagrees with what it drew. Whether any part of the 264// cluster survives then depends on where in the row it landed, which makes it 265// useless as evidence about the renderer. ghostty places it correctly in both 266// width modes, so the assertion still bites there. 267const keycap = "1\ufe0f\u20e3" 268 269// oracles are the emulators every fuzz target runs against. 270// oracleSpec is one emulator the fuzz targets run against. 271type oracleSpec struct { 272name string 273mk func (* testing.T ,int ,int ,bool )oracle 274// drops are codepoints this emulator swallows outright, so they never read 275// back from any cell. 276drops []rune 277// skip are whole clusters this emulator cannot place coherently, so where 278// their codepoints land says nothing about the renderer. 279skip []string 280} 281 282var oracles = []oracleSpec { 283 {name :"ghostty" ,mk :newGhostty ,drops : []rune {variationSelector16 }}, 284// x/vt has no legacy width mode, so its width model is fixed regardless of 285// what the program asked for. 286 { 287name :"vt" , 288mk :func (t * testing.T ,w ,h int ,_ bool )oracle {return newVT (t ,w ,h ) }, 289drops : []rune {variationSelector16 }, 290skip : []string {keycap }, 291 }, 292} 293 294// addSeeds seeds a fuzz target from the shared corpus. 295func addSeeds (f * testing.F ) { 296f .Helper () 297 298for _ ,seed := range conformance .Seeds () { 299f .Add (seed ) 300 } 301 302// Short raw inputs too, so the fuzzer has small programs to grow rather 303// than having to shrink its way down from the structured seeds. 304f .Add ([]byte (nil )) 305f .Add ([]byte {0 ,0 ,0 }) 306f .Add ([]byte {2 ,12 ,2 ,12 ,2 ,12 }) 307} 308 309// FuzzRenderer is the main coverage-guided target. It reads each input as a 310// program of renderer operations, replays it incrementally, and requires the 311// resulting screen to match a full repaint of the same final buffer. 312// 313// This is the property worth fuzzing because it is the renderer's whole 314// contract: however it chooses to reach a frame, the frame must look the same as 315// if it had been painted from scratch. Cursor tracking, dirty regions and scroll 316// optimisation are all implementation details in service of that. 317func FuzzRenderer (f * testing.F ) { 318addSeeds (f ) 319 320f .Fuzz (func (t * testing.T ,data []byte ) { 321p := conformance .DecodeProgram (data ) 322if len (p .Ops )== 0 { 323return 324 } 325 326for _ ,o := range oracles { 327got := runIncremental (t ,p ,o .mk ) 328want := runFullRepaint (t ,p ,o .mk ) 329compare (t ,p ,o .name ,got ,want ) 330 } 331 }) 332} 333 334// FuzzRendererIdempotent checks that rendering an unchanged buffer twice leaves 335// the screen alone. 336// 337// This is a weaker property than the one above, but it fails differently and on 338// smaller inputs. A renderer whose model is wrong in a way that cancels out over 339// a whole frame can slip past the differential test and still fail here. 340func FuzzRendererIdempotent (f * testing.F ) { 341addSeeds (f ) 342 343f .Fuzz (func (t * testing.T ,data []byte ) { 344p := conformance .DecodeProgram (data ) 345if len (p .Ops )== 0 { 346return 347 } 348 349for _ ,o := range oracles { 350r := newRunner (t ,p ,o .mk ) 351 352for _ ,op := range p .Ops { 353r .step (t ,op ) 354 } 355r .rend .Render (r .buf .RenderBuffer ) 356r .flush (t ) 357before := r .screen (t ) 358 359// A second render of the same buffer should be a no-op. 360r .rend .Render (r .buf .RenderBuffer ) 361r .flush (t ) 362after := r .screen (t ) 363 364compare (t ,p ,o .name + ", second render of an unchanged buffer" ,after ,before ) 365r .close () 366 } 367 }) 368} 369 370// FuzzRedrawResyncs checks that a forced repaint recovers from any state the 371// renderer managed to get itself into. 372// 373// Redraw is the escape hatch the whole design leans on. If it does not reliably 374// resynchronise then no amount of care in the incremental path is enough, 375// because there is no way back from a bad state. 376func FuzzRedrawResyncs (f * testing.F ) { 377addSeeds (f ) 378 379f .Fuzz (func (t * testing.T ,data []byte ) { 380p := conformance .DecodeProgram (data ) 381if len (p .Ops )== 0 { 382return 383 } 384 385for _ ,o := range oracles { 386r := newRunner (t ,p ,o .mk ) 387 388for _ ,op := range p .Ops { 389r .step (t ,op ) 390 } 391 392// However confused the renderer now is, a redraw must land on the 393// same screen as painting this buffer onto a fresh terminal. 394r .rend .Redraw (r .buf .RenderBuffer ) 395r .flush (t ) 396got := r .screen (t ) 397r .close () 398 399want := runFullRepaint (t ,p ,o .mk ) 400compare (t ,p ,o .name + ", after Redraw" ,got ,want ) 401 } 402 }) 403} 404 405// drawnRow returns the text a buffer row holds that the emulator has room to 406// show: the content of every cell in order, skipping the placeholders that 407// continue a wide cell, and stopping at the first cluster that runs past the 408// right margin. 409// 410// This, and not the string the program asked to draw, is what the renderer was 411// given to paint and could paint. A cluster whose start column falls past the 412// right edge is never placed in the buffer at all. One that starts inside the 413// row but runs off the end cannot be shown whole, and the renderer paints such 414// a row with autowrap off, so it clips rather than spilling onto the next row 415// and everything after it is lost too. 416// 417// The walk stops at the first cluster the emulator measures differently than 418// the renderer does, and that cluster is not included either. 419// 420// Up to that point the two agree on every column, so the renderer put each 421// cluster exactly where the emulator was expecting it and it had to land. The 422// first disagreement ends that. The renderer goes on positioning by its own 423// model, so the very next write lands inside the cluster that disagreed and 424// destroys it, and every write after that is similarly adrift. A codepoint 425// missing from there on says the two disagree about width, which is a given 426// here, not that the renderer dropped it. 427// 428// This still bites where it matters. ghostty measures the drift-prone clusters 429// exactly as the renderer's matching width model does, in both legacy and mode 430// 2027, so the assertion covers all of them there. It is x/vt driven in legacy 431// mode that mostly falls out, and only because x/vt has no legacy mode to 432// drive. 433func drawnRow (buf uv.ScreenBuffer ,y int ,width func (string )int )string { 434var b strings.Builder 435for x := 0 ;x < buf .Width ();x ++ { 436c := buf .CellAt (x ,y ) 437if c == nil || c .Width == 0 { 438continue 439 } 440if width (c .Content )!= c .Width || x + c .Width > buf .Width () { 441break 442 } 443b .WriteString (c .Content ) 444 } 445return b .String () 446} 447 448// FuzzScreenShowsContent checks that everything drawn actually reaches the 449// screen, without reference to any other render. 450// 451// The three targets above are all differential: they compare one render against 452// another. That makes them blind to a renderer that paints the same wrong thing 453// every time, and writing a cluster into a column the terminal has already 454// filled is exactly that kind of consistent wrongness. A first paint and a full 455// repaint agree perfectly while both drop a glyph. 456// 457// So this target asserts something absolute instead. The most recently drawn 458// line must appear on its row, with every codepoint of every drift-prone 459// cluster it contained present at least as many times as it was drawn. 460// 461// Codepoints are counted one at a time rather than as whole clusters because 462// the emulators disagree about how a cluster lands in cells: x/vt splits a 463// keycap across two, so the bytes are on the row but not contiguous. Counting 464// them separately keeps the assertion blind to layout and sharp about loss, 465// which matters, since dropping a combining mark is the bug this target was 466// written to catch. Where an emulator swallows a codepoint outright it is 467// listed in that oracle's drops, so the tolerance is per-emulator and named 468// rather than applied to everyone. Extra occurrences are tolerated as 469// emulator-defined residue from earlier draws. "The glyph reached the screen at 470// all" is a floor that holds under every width model. 471// 472// Only the last draw is checked, not every draw. A resize can clip rows drawn 473// earlier or shrink the screen below them, and whether clipped content should 474// reappear is emulator-defined, so asserting on it would be noise. The last 475// draw is always made at the current width and within the current height, so it 476// is always fully visible and always fair to check. 477func FuzzScreenShowsContent (f * testing.F ) { 478addSeeds (f ) 479 480f .Fuzz (func (t * testing.T ,data []byte ) { 481p := conformance .DecodeProgram (data ) 482if len (p .Ops )== 0 { 483return 484 } 485 486// Track the row of the last line drawn. A Clear invalidates it, since 487// nothing is drawn after a clear unless a later DrawLine says so. 488lastY := - 1 489for _ ,op := range p .Ops { 490switch op .Kind { 491case conformance .OpDrawLine : 492lastY = op .Y 493case conformance .OpClear : 494lastY = - 1 495 } 496 } 497if lastY < 0 { 498return 499 } 500 501for _ ,o := range oracles { 502r := newRunner (t ,p ,o .mk ) 503for _ ,op := range p .Ops { 504r .step (t ,op ) 505 } 506r .rend .Render (r .buf .RenderBuffer ) 507r .flush (t ) 508 509// A resize after the last draw can shrink the screen below that 510// row, in which case it is off-screen and there is nothing to 511// assert. 512if lastY >= r .h { 513r .close () 514continue 515 } 516 517screen := r .screen (t ) 518screenY := lastY + r .frameTop () 519drawn := drawnRow (r .buf ,lastY ,func (cluster string )int { 520return clusterWidth (t ,o ,p .GraphemeWidth ,cluster ) 521 }) 522r .close () 523 524for _ ,cluster := range conformance .DriftClusters () { 525want := strings .Count (drawn ,cluster ) 526if want == 0 || slices .Contains (o .skip ,cluster ) { 527continue 528 } 529// Count codepoints, not clusters; extras are residue. 530for _ ,r := range cluster { 531if slices .Contains (o .drops ,r ) { 532continue 533 } 534wantRune := strings .Count (drawn ,string (r )) 535gotRune := strings .Count (screen [screenY ],string (r )) 536if gotRune < wantRune { 537t .Errorf ("%s: row %d shows %q of cluster %q %d times but at least %d were drawn\n" + 538" screen %q\n" + 539" drawn %q\n" + 540"program:\n%s" , 541o .name ,lastY ,string (r ),cluster ,gotRune ,wantRune ,screen [screenY ],drawn ,p ) 542return 543 } 544 } 545 } 546 } 547 }) 548} 549 550// TestInlineSeedOccupiesTheRowsItClaims measures where an inline frame starts 551// instead of trusting the arithmetic. 552// 553// frameTop reports InlineRowsAbove, and the content oracle reads screen rows 554// through it. That is only true while the seeded rows each occupy exactly one 555// row, so it is checked here against a real emulator at the narrowest width a 556// program can ask for: seven columns of "above %d" in a six-column screen used 557// to wrap into four rows, and the oracle then read a seeded row and reported the 558// renderer as having lost content it never drew. 559func TestInlineSeedOccupiesTheRowsItClaims (t * testing.T ) { 560for _ ,spec := range oracles { 561t .Run (spec .name ,func (t * testing.T ) { 562narrowest := conformance .DecodeProgram (nil ) 563p := conformance.Program { 564Width :narrowest .Width , 565Height :2 , 566Inline :true , 567 } 568r := newRunner (t ,p ,spec .mk ) 569defer r .term .Close () 570 571for y := range conformance .InlineRowsAbove { 572if got := r .term .Row (t ,y );got == "" { 573t .Errorf ("row %d is blank, so the seeded rows do not reach it" ,y ) 574 } 575 } 576if got := r .term .Row (t ,r .frameTop ());got != "" { 577t .Errorf ("the frame starts at row %d, but the rows above spilled into it: %q" , 578r .frameTop (),got ) 579 } 580 }) 581 } 582} 583 584// TestInlineShrinkLeavesTheFramesOwnRows checks, against a real emulator, what an 585// inline frame that gives up rows looks like afterwards. 586// 587// The erase starts at the first row the frame gave up, so the rows it still owns 588// are left alone and nothing has to be painted back into them. Asserting on the 589// screen rather than on the escape sequence is the point: there is no single 590// correct sequence, and the byte-level tests in the root package could not tell 591// "left alone" from "erased and repainted". 592func TestInlineShrinkLeavesTheFramesOwnRows (t * testing.T ) { 593for _ ,spec := range oracles { 594t .Run (spec .name ,func (t * testing.T ) { 595p := conformance.Program {Width :6 ,Height :4 ,Inline :true } 596r := newRunner (t ,p ,spec .mk ) 597defer r .term .Close () 598 599r .step (t , conformance.Op {Kind :conformance .OpDrawLine ,Y :1 ,Text :"ab" }) 600r .step (t , conformance.Op {Kind :conformance .OpDrawLine ,Y :3 ,Text :"zz" }) 601r .step (t , conformance.Op {Kind :conformance .OpRender }) 602 603top := r .frameTop () 604if got := r .term .Row (t ,top + 1 );got != "ab" { 605t .Fatalf ("row 1 of the frame reads %q before the shrink" ,got ) 606 } 607 608// Give up the bottom two rows. 609r .step (t , conformance.Op {Kind :conformance .OpResize ,W :6 ,H :2 }) 610r .step (t , conformance.Op {Kind :conformance .OpRender }) 611 612if got := r .term .Row (t ,top + 1 );got != "ab" { 613t .Errorf ("the frame still owns row 1, but it reads %q after the shrink" ,got ) 614 } 615if got := r .term .Row (t ,top + 3 );got != "" { 616t .Errorf ("row 3 was given up, so it should be blank, got %q" ,got ) 617 } 618for y := range conformance .InlineRowsAbove { 619if got := r .term .Row (t ,y );got == "" { 620t .Errorf ("the erase reached row %d, above the frame" ,y ) 621 } 622 } 623 }) 624 } 625}