okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
5e21978
upstream
1// Package conformance drives the renderer against real terminal emulators and 2// compares the resulting screen against ground truth. 3// 4// The renderer keeps a model of what the terminal is currently showing and 5// writes only the difference between that model and the next frame. When the 6// model drifts out of step with reality the output is still valid ANSI and 7// still looks plausible; it just paints the wrong thing. Asserting on escape 8// sequences cannot catch that, because there is no single correct sequence to 9// compare against. 10// 11// So these tests compare against ground truth instead: run a sequence of 12// frames incrementally on one terminal, paint the final frame from scratch on 13// another, and require the two screens to match. Any disagreement is a bug in 14// the incremental path. 15// 16// This lives in its own module because the reference emulator is a cgo package 17// with a newer Go requirement, and neither cost belongs in the library that 18// ships to users. 19package conformance 20 21import ( 22"fmt" 23"strings" 24 25"github.com/charmbracelet/x/ansi" 26) 27 28// The fuzzer needs to explore sequences of renderer operations, not just 29// strings of text, because the bugs worth finding live in the interaction 30// between frames. A single frame is nearly always painted correctly; it is the 31// second frame, drawn as a diff against a model of the first, that goes wrong. 32// 33// So a fuzz input is decoded as a little program: a screen size, a set of 34// renderer options, then a list of operations to run in order. Go's fuzzer 35// mutates the raw bytes and coverage feedback steers it toward programs that 36// reach new code in the renderer. That is the part a hand-written table of 37// cases cannot do, since interesting programs are combinations nobody thinks 38// to write down. 39 40// Op is a single step in a fuzz program. 41type Op struct { 42// Kind selects which operation to perform. 43Kind OpKind 44 45// Y is the row an OpDrawLine writes to, already reduced modulo the screen 46// height so it is always in range. 47Y int 48 49// Text is the content an OpDrawLine writes. 50Text string 51 52// N is a small integer argument, used by operations like OpMoveTo. 53N int 54 55// W and H are the new dimensions for an OpResize, already clamped to the 56// valid range. 57W ,H int 58} 59 60// OpKind enumerates the renderer operations a fuzz program can perform. 61type OpKind uint8 62 63const ( 64// OpDrawLine writes Text into row Y of the pending frame. 65OpDrawLine OpKind = iota 66 67// OpClear blanks the pending frame. 68OpClear 69 70// OpRender flushes the pending frame through the renderer's incremental 71// path. This is the operation under test; every other op exists to get 72// the renderer into an interesting state before one of these. 73OpRender 74 75// OpRedraw forces a full repaint, which should always resynchronise the 76// renderer's model with the terminal. 77OpRedraw 78 79// OpMoveTo repositions the cursor, which is a common source of drift 80// because it is where a wrong column model becomes a wrong write. 81OpMoveTo 82 83// OpResize changes the terminal and buffer dimensions to W by H. Resizing 84// is the biggest geometry change a terminal undergoes, and a renderer that 85// carries stale geometry across one will paint the wrong thing, so it is 86// worth fuzzing directly. 87OpResize 88 89// OpErase marks the screen to be repainted on the next render, without 90// performing that render. OpRedraw does both at once and so can never be 91// separated from what happens in between; an erase left pending across a 92// resize is a different thing entirely, and the one applications drawing 93// inline frames actually do when the frame changes shape. 94OpErase 95 96opKindCount 97) 98 99// opTable maps an input byte onto an operation. Its length is fixed and never 100// changes, so adding a kind rewrites only the slots it is given: every corpus 101// entry minted before it keeps decoding to the program it was minimised for. 102// Choosing the kind by [OpKind] count instead made every saved entry mean 103// something else the moment an op was added, silently, while still passing. 104// 105// TestCorpusDecodesStably pins what the saved entries decode to, so a change 106// here fails loudly and the entries can be re-minted on purpose. 107var opTable = [16 ]OpKind { 108OpDrawLine ,OpClear ,OpRender ,OpRedraw , 109OpMoveTo ,OpResize ,OpErase ,OpDrawLine , 110OpClear ,OpRender ,OpRedraw ,OpMoveTo , 111OpResize ,OpErase ,OpDrawLine ,OpRender , 112} 113 114// opByte is the input byte that decodes to kind. A hand-written seed goes 115// through this rather than using the enum's numeric value, which stopped being 116// the same thing when the decoder moved to a fixed slot table. 117func opByte (kind OpKind )byte { 118for slot ,k := range opTable { 119if k == kind { 120return byte (slot ) 121 } 122 } 123panic ("opTable does not name " + kind .String ()) 124} 125 126// Resize bounds. Screens stay small so failures stay readable, but the range 127// is wide enough to shrink below and grow above the starting size, which is 128// where stale-geometry bugs show up. Exported so the decoder's own tests can 129// check that every OpResize lands inside them. 130const ( 131MinResizeW ,MaxResizeW = 4 ,32 132MinResizeH ,MaxResizeH = 2 ,8 133) 134 135// InlineRowsAbove is how many rows of someone else's output sit above an inline 136// frame. They are the point, not scenery: an erase that reaches one row too far 137// lands in them, and against a blank screen that mistake reads back as blanks 138// and never shows. 139const InlineRowsAbove = 2 140 141// InlineSeedWidth is how many columns each of those rows uses. It has to fit the 142// narrowest screen a program can ask for, or the text wraps, the rows above take 143// more rows than they were given, and the frame no longer starts where 144// InlineRowsAbove says it does. 145const InlineSeedWidth = 2 146 147// InlineTermHeight is the terminal height an inline program runs against. It 148// leaves a row below the tallest frame: a frame reaching the last row scrolls on 149// the next newline, and a scrolled screen sits at a different absolute row in 150// every run, which a differential comparison would report as a bug. 151const InlineTermHeight = InlineRowsAbove + MaxResizeH + 2 152 153// String makes failures readable, since a raw OpKind number tells you nothing 154// about what the renderer was asked to do. 155func (k OpKind )String ()string { 156switch k { 157case OpDrawLine : 158return "DrawLine" 159case OpClear : 160return "Clear" 161case OpRender : 162return "Render" 163case OpRedraw : 164return "Redraw" 165case OpMoveTo : 166return "MoveTo" 167case OpResize : 168return "Resize" 169case OpErase : 170return "Erase" 171default : 172return fmt .Sprintf ("OpKind(%d)" ,uint8 (k )) 173 } 174} 175 176// Program is a decoded fuzz input: a terminal size, renderer settings, and the 177// operations to run. 178type Program struct { 179// Width and Height are the terminal size, clamped to a small range. Small 180// screens are deliberate: they make wrapping and scrolling happen often, 181// and they keep failure output readable. 182Width ,Height int 183 184// GraphemeWidth selects the renderer's width model, and is matched by the 185// emulator's own mode so both sides agree or disagree deliberately rather 186// than by accident. 187GraphemeWidth bool 188 189// ScrollOptim enables the hardware-scroll path, which is worth fuzzing 190// separately because it moves glyphs bodily and carries their painted 191// widths with them. 192ScrollOptim bool 193 194// Inline draws the frame inline instead of over the whole screen, the way 195// a prompt or a progress display does. It is a different renderer 196// entirely in the ways that matter here: the frame is shorter than the 197// terminal, so the model cannot assume it owns every row, and the cursor 198// moves relatively, so there is no absolute move to fall back on when the 199// model loses track of where it is. Bugs that need those conditions are 200// invisible to a fullscreen-only harness. 201Inline bool 202 203// Ops are the operations to run in order. 204Ops []Op 205} 206 207// String renders a program as reproducible Go-ish pseudocode. A fuzz failure is 208// only useful if you can see what it did, and a hex dump of the input does not 209// tell you that. 210func (p Program )String ()string { 211var b strings.Builder 212fmt .Fprintf (& b ,"size=%dx%d graphemeWidth=%v scrollOptim=%v inline=%v\n" , 213p .Width ,p .Height ,p .GraphemeWidth ,p .ScrollOptim ,p .Inline ) 214for i ,op := range p .Ops { 215switch op .Kind { 216case OpDrawLine : 217fmt .Fprintf (& b ," %2d. DrawLine(y=%d, %q)\n" ,i ,op .Y ,op .Text ) 218case OpMoveTo : 219fmt .Fprintf (& b ," %2d. MoveTo(%d)\n" ,i ,op .N ) 220case OpResize : 221fmt .Fprintf (& b ," %2d. Resize(%dx%d)\n" ,i ,op .W ,op .H ) 222default : 223fmt .Fprintf (& b ," %2d. %s\n" ,i ,op .Kind ) 224 } 225 } 226return b .String () 227} 228 229// corpusAlphabet is the set of clusters a fuzz program can draw. The fuzzer 230// picks from this by index rather than consuming raw UTF-8, because letting it 231// invent its own bytes wastes almost all its time on invalid encodings and 232// widthless control characters. Choosing from a curated set means every 233// generated line is made of clusters that actually stress the width model. 234// 235// The clusters are grouped by width behaviour. Everything from 236// driftClusterOffset onward is a cluster where legacy wcwidth and grapheme 237// width disagree, and those are the ones that provoke column drift. The 238// clusters before that point exercise distinct code paths (combining marks, 239// text presentation, a different wide script) without drifting, so the fuzzer 240// explores them too. 241var corpusAlphabet = []string { 242// Narrow ASCII. 243"a" ,"b" ,"c" ,"X" ," " ,"." ,"|" ,"#" , 244// Wide CJK. 245"世" ,"界" , 246// Wide Hangul, a different script in the same width class. 247"가" ,"한" , 248// Plain emoji, width 2, no modifiers. 249"\U0001fae0" ,// melting face 250// Combining marks: a base plus a zero-width combiner, width 1 total. 251"e\u0301" ,// é 252"n\u0303" ,// ñ 253// VS15 text presentation, forces narrow rendering, width 1. 254"\u2639\ufe0e" ,// frowning face, text style 255// ZWJ sequences where both width models agree at 2. 256"\U0001f469\u200d\U0001f4bb" ,// woman technologist 257"\U0001f426\u200d\U0001f525" ,// phoenix 258// Skin tone modifiers. Legacy wcwidth measures these at 4, two columns for 259// the base and two for the modifier, which is what ghostty's legacy mode 260// paints. They sit before driftClusterOffset only so the seeds stay pointed 261// at the sequences terminals disagree about; the fuzzer still reaches them, 262// and a four-column cluster is worth reaching. 263"\U0001f44d\U0001f3fd" ,// thumbs up, medium skin 264"\U0001f44b\U0001f3ff" ,// waving hand, dark skin 265 266// --- drift-prone clusters below: legacy wcwidth != grapheme width --- 267 268// VS16 emoji presentation: legacy says 1, grapheme says 2. 269"\u2639\ufe0f" ,// frowning face 270"\u2764\ufe0f" ,// heart 271"\u2708\ufe0f" ,// airplane 272// ZWJ with VS16: legacy says 1, grapheme says 2. 273"\u26d3\ufe0f\u200d\U0001f4a5" ,// broken chain 274// Regional indicators: legacy width varies wildly across terminals, 275// grapheme says 2. A single unpaired indicator is the most ambiguous case. 276"\U0001f1fa" ,// single regional indicator U 277"\U0001f1fa\U0001f1f8" ,// flag US 278"\U0001f1ef\U0001f1f5" ,// flag JP 279// Keycap: legacy says 1, grapheme says 2. 280"1\ufe0f\u20e3" ,// keycap 1 281} 282 283// driftClusterOffset is where the drift-prone clusters begin in corpusAlphabet. 284// Everything from here on is a VS16, ZWJ, regional-indicator, or keycap sequence 285// where legacy wcwidth and grapheme width disagree. 286const driftClusterOffset = 20 287 288// driftIndices are the positions in corpusAlphabet whose clusters legacy 289// wcwidth and grapheme width disagree about. 290// 291// Computed rather than written down, because the width tables underneath move 292// with Unicode releases. A cluster that provokes drift today may not tomorrow, 293// and a hardcoded list would keep pointing at it while quietly testing nothing. 294// TestDriftPremise fails if the list ever comes out empty. 295func driftIndices () []int { 296var idx []int 297for i := driftClusterOffset ;i < len (corpusAlphabet );i ++ { 298c := corpusAlphabet [i ] 299if ansi .StringWidthWc (c )!= ansi .StringWidth (c ) { 300idx = append (idx ,i ) 301 } 302 } 303return idx 304} 305 306// DriftClusters are the clusters that legacy wcwidth and grapheme width 307// disagree about, and so the ones that provoke column drift. 308func DriftClusters () []string { 309var out []string 310for _ ,i := range driftIndices () { 311out = append (out ,corpusAlphabet [i ]) 312 } 313return out 314} 315 316// CorpusAlphabet returns the clusters a fuzz program can draw, for tests that 317// need to reason about the corpus. 318func CorpusAlphabet () []string { 319return append ([]string (nil ),corpusAlphabet ... ) 320} 321 322// Seeds is the starting corpus for the fuzz targets. 323// 324// Seeds matter more than they look. Coverage-guided fuzzing explores outward 325// from what it is given, and random bytes mostly decode into programs that draw 326// plain ASCII and never reach the width-handling code at all. These put the 327// fuzzer inside the interesting region immediately: each one draws a 328// drift-provoking cluster and renders more than once, so mutation starts from 329// programs that already exercise the incremental path against awkward content. 330func Seeds () [][]byte { 331// endOfLine is the alphabet index that terminates a drawn line. 332endOfLine := byte (len (corpusAlphabet )) 333 334var seeds [][]byte 335for _ ,i := range driftIndices () { 336cluster := byte (i ) 337 338// Cover both width models crossed with the scroll path and the frame 339// mode, since a bug can hide in any one of the combinations. 340for mode := byte (0 );mode < 8 ;mode ++ { 341seeds = append (seeds , []byte { 34214 ,2 ,// 20x4 343mode , 344// Two adjacent clusters, then render: drift accumulates across 345// neighbours, so adjacency is the productive case. 346opByte (OpDrawLine ),0 ,cluster ,cluster ,endOfLine , 347opByte (OpRender ), 348// A second frame, so the renderer has to diff rather than 349// paint, which is where the interesting bugs live. 350opByte (OpDrawLine ),1 ,cluster ,0 ,cluster ,endOfLine , 351opByte (OpRender ), 352// Shrink row 0, which leaves residue behind if the renderer's 353// column model is wrong. 354opByte (OpDrawLine ),0 ,0 ,cluster ,endOfLine , 355opByte (OpRender ), 356 }) 357 } 358 } 359 360// Structural seeds, so the fuzzer does not have to discover Clear, Redraw 361// and MoveTo by chance. They still draw drifting clusters, so they explore 362// those operations somewhere worth being. 363if drift := driftIndices ();len (drift )> 0 { 364first := byte (drift [0 ]) 365last := byte (drift [len (drift )- 1 ]) 366 367seeds = append (seeds , 368 []byte { 36914 ,2 ,0 , 370opByte (OpDrawLine ),0 ,first ,last ,8 ,endOfLine , 371opByte (OpRender ), 372opByte (OpClear ), 373opByte (OpRender ), 374opByte (OpRedraw ), 375 }, 376 []byte { 37710 ,1 ,1 , 378opByte (OpDrawLine ),0 ,last ,last ,endOfLine , 379opByte (OpRender ), 380opByte (OpMoveTo ),3 , 381opByte (OpDrawLine ),0 ,0 ,endOfLine , 382opByte (OpRender ), 383 }, 384 ) 385 386// Resize seeds. Resizing is the biggest geometry change a terminal 387// undergoes, and the interesting failures come from carrying stale 388// geometry across one, so each seed draws, resizes, and draws again. 389// The dimensions are encoded relative to the resize bounds: a byte b 390// maps to minResize + b % (max-min+1). 391// The decoder maps a byte to a size within the resize bounds, so a seed 392// that wants a particular size has to encode it the same way. An inline 393// program may collapse to no rows, so its heights start from zero and 394// the same byte means a different height in each mode; pass the low 395// bound the target mode uses. 396resizeW := func (w int )byte {return byte ((w - MinResizeW )% (MaxResizeW - MinResizeW + 1 )) } 397resizeH := func (h ,lo int )byte {return byte ((h - lo )% (MaxResizeH - lo + 1 )) } 398for mode := byte (0 );mode < 8 ;mode ++ { 399// An inline frame may collapse to nothing, so its heights decode 400// from a lower bound. Passing the fullscreen bound for all eight 401// modes silently shifted every inline resize in these seeds. 402lo := MinResizeH 403if mode & 4 != 0 { 404lo = 0 405 } 406seeds = append (seeds , []byte { 40714 ,2 ,// 20x4 408mode , 409opByte (OpDrawLine ),0 ,first ,first ,last ,last ,endOfLine , 410opByte (OpRender ), 411// Shrink, then draw a line that only fits the smaller screen. 412opByte (OpResize ),resizeW (8 ),resizeH (3 ,lo ), 413opByte (OpDrawLine ),0 ,first ,0 ,endOfLine , 414opByte (OpRender ), 415// Grow back, which is where clipped content can leave residue. 416opByte (OpResize ),resizeW (24 ),resizeH (6 ,lo ), 417opByte (OpDrawLine ),0 ,last ,last ,last ,endOfLine , 418opByte (OpRender ), 419 }) 420 } 421 422// An inline frame that changes shape. The erase is left pending across 423// the resize, which is what an application does when its view swaps for 424// a shorter one, and it is the case a single OpRedraw cannot express. 425// Where the erase starts from decides how much of the taller frame it 426// reaches, so a renderer that has lost track of the cursor leaves the 427// top of the old frame on screen. 428seeds = append (seeds , []byte { 42914 ,5 ,// 20x7 4304 ,// inline 431opByte (OpDrawLine ),0 ,first ,last ,endOfLine , 432opByte (OpDrawLine ),4 ,first ,endOfLine , 433opByte (OpRender ), 434opByte (OpErase ), 435opByte (OpResize ),resizeW (20 ),resizeH (2 ,0 ), 436opByte (OpDrawLine ),0 ,last ,endOfLine , 437opByte (OpRender ), 438 }) 439 } 440 441return seeds 442} 443 444// decoder hands out small values from the fuzzer's byte string. Running off the 445// end is normal and expected: the fuzzer supplies inputs of every length, so 446// the decoder reports exhaustion rather than treating a short input as an 447// error, and the caller simply stops building the program. 448type decoder struct { 449buf []byte 450pos int 451} 452 453// next returns the next byte, or false once the input is exhausted. 454func (d * decoder )next () (byte ,bool ) { 455if d .pos >= len (d .buf ) { 456return 0 ,false 457 } 458b := d .buf [d .pos ] 459d .pos ++ 460return b ,true 461} 462 463// intn returns a value in [0,n), or false if the input is exhausted. Taking one 464// byte per value keeps the mapping from input bytes to program structure simple 465// enough that the fuzzer's byte-level mutations translate into small, targeted 466// program changes, which is what makes coverage feedback effective. 467func (d * decoder )intn (n int ) (int ,bool ) { 468b ,ok := d .next () 469if ! ok || n <= 0 { 470return 0 ,false 471 } 472return int (b )% n ,true 473} 474 475// intrange returns a value in [lo,hi], or false if the input is exhausted. 476func (d * decoder )intrange (lo ,hi int ) (int ,bool ) { 477v ,ok := d .intn (hi - lo + 1 ) 478if ! ok { 479return 0 ,false 480 } 481return lo + v ,true 482} 483 484// DecodeProgram interprets fuzzer bytes as a program. 485// 486// Every byte string maps to some valid program, and the decoder never fails. 487// That matters: if malformed inputs were rejected the fuzzer would spend its 488// budget rediscovering the input format instead of exploring renderer 489// behaviour. 490func DecodeProgram (data []byte )Program { 491d := & decoder {buf :data } 492 493// Sizes stay small and odd-ish so wrapping and scrolling are common. A 494// wide cluster landing on the last column is one of the most productive 495// cases, and narrow screens hit it constantly. 496w ,_ := d .intn (24 ) 497h ,_ := d .intn (6 ) 498p := Program {Width :w + 6 ,Height :h + 2 } 499 500if b ,ok := d .next ();ok { 501// Independent bits, so the fuzzer can flip the width model, the scroll 502// path or the frame mode without disturbing the rest of the program. 503p .GraphemeWidth = b & 1 != 0 504p .ScrollOptim = b & 2 != 0 505p .Inline = b & 4 != 0 506 } 507 508// Bounded so a single input cannot run for an unreasonable time; the 509// fuzzer is far more effective with many short programs than a few long 510// ones. 511const maxOps = 24 512 513// The live dimensions, which an OpResize changes. DrawLine and MoveTo 514// bounds are computed from these so a line drawn after a resize is always 515// valid for the current screen, not the one the program started with. 516curW ,curH := p .Width ,p .Height 517 518for len (p .Ops )< maxOps { 519slot ,ok := d .intn (len (opTable )) 520if ! ok { 521break 522 } 523 524op := Op {Kind :opTable [slot ]} 525switch op .Kind { 526case OpDrawLine : 527// A frame with no rows has nowhere to draw. Skip the op rather than 528// end the program: an inline frame may collapse to nothing and then 529// grow again, and truncating here made everything after a collapse 530// unreachable, including the redraw that proves it recovered. 531if curH <= 0 { 532continue 533 } 534y ,ok := d .intn (curH ) 535if ! ok { 536return p 537 } 538op .Y = y 539 540// Build the line from whole clusters and stop before the right 541// margin, so content cannot wrap onto the next row. Wrapping is a 542// separate concern, and content spilling across rows would make 543// failures ambiguous about which row was actually wrong. 544var sb strings.Builder 545used := 0 546for { 547idx ,ok := d .intn (len (corpusAlphabet )+ 1 ) 548if ! ok { 549return p 550 } 551// The extra index terminates the line, letting the fuzzer 552// control length instead of always filling the row. 553if idx == len (corpusAlphabet ) { 554break 555 } 556g := corpusAlphabet [idx ] 557gw := ansi .StringWidth (g ) 558if used + gw > curW - 1 { 559break 560 } 561sb .WriteString (g ) 562used += gw 563 } 564op .Text = sb .String () 565 566case OpMoveTo : 567// Likewise, a frame with no columns has nowhere to move to. 568if curW <= 0 { 569continue 570 } 571n ,ok := d .intn (curW ) 572if ! ok { 573return p 574 } 575op .N = n 576 577case OpResize : 578nw ,ok := d .intrange (MinResizeW ,MaxResizeW ) 579if ! ok { 580return p 581 } 582// An inline frame is allowed to collapse to nothing. A view can 583// vanish while the application keeps drawing, and a frame with no 584// rows leaves the erase below it no row of its own to start from, 585// which is the case most likely to reach above the frame. 586minH := MinResizeH 587if p .Inline { 588minH = 0 589 } 590nh ,ok := d .intrange (minH ,MaxResizeH ) 591if ! ok { 592return p 593 } 594// An inline frame is never narrowed: the terminal rewraps the rows 595// it holds and carries the cursor somewhere a relative move cannot 596// find again, which is a property of the mode rather than a defect. 597// Widening is safe and worth fuzzing. 598if p .Inline { 599nw = max (nw ,curW ) 600 } 601 602op .W ,op .H = nw ,nh 603curW ,curH = nw ,nh 604 } 605 606p .Ops = append (p .Ops ,op ) 607 } 608 609return p 610}