detached ab6686c

oracle_test.go RawHistoryFolder

1package vt_test
2
3import (
4	"bufio"
5	"bytes"
6	"crypto/sha256"
7	"encoding/hex"
8	"errors"
9	"flag"
10	"fmt"
11	"hash"
12	"math/rand/v2"
13	"os"
14	"path/filepath"
15	"strings"
16	"testing"
17
18	vt "github.com/bnema/vev-vt"
19	"github.com/bnema/vev-vt/ansi"
20	core "github.com/bnema/vev-vt/core"
21	"github.com/bnema/vev-vt/graphics"
22	"github.com/bnema/vev-vt/html"
23)
24
25// The differential oracle drives seeded terminal workloads through the public
26// pipeline (VT parsing, damage, history, codecs, ANSI and HTML renderers) and
27// hashes every observable result at checkpoints. Performance work must keep
28// the golden file byte-identical; behavior changes must regenerate it with
29// -oracle.update and justify the difference.
30var (
31	oracleUpdate = flag.Bool("oracle.update", false, "rewrite testdata/oracle/golden.txt")
32	oracleDump   = flag.String("oracle.dump", "", "write full plain-text transcripts to this directory")
33)
34
35const (
36	oracleScenarios  = 36
37	oracleSteps      = 240
38	oracleCheckEvery = 20
39	oracleGolden     = "testdata/oracle/golden.txt"
40)
41
42type oracleScenario struct {
43	seed      uint64
44	cols      int
45	rows      int
46	history   *vt.HistoryConfig
47	byteSplit bool
48	evictHook bool
49}
50
51func oracleScenarioFor(i int) oracleScenario {
52	r := rand.New(rand.NewPCG(uint64(i), 0x5eed))
53	sc := oracleScenario{seed: uint64(i), cols: 8 + r.IntN(110), rows: 3 + r.IntN(40)}
54	switch i % 4 {
55	case 1:
56		sc.history = &vt.HistoryConfig{MaxRows: 50 + r.IntN(400), ChunkRows: 1 + r.IntN(16)}
57	case 2:
58		sc.history = &vt.HistoryConfig{MaxBytes: uint64(64<<10 + r.IntN(512<<10)), ChunkRows: 1 + r.IntN(64)}
59	case 3:
60		sc.history = &vt.HistoryConfig{MaxRows: 2000, MaxBytes: 4 << 20}
61	}
62	sc.byteSplit = i%5 == 0
63	sc.evictHook = i%3 == 0
64	return sc
65}
66
67type oracleRun struct {
68	t       *testing.T
69	sc      oracleScenario
70	r       *rand.Rand
71	screen  *vt.Screen
72	ansiR   *ansi.Renderer
73	ansi256 *ansi.Renderer
74	htmlR   *html.Renderer
75	replay  *vt.Screen
76	// replay256 receives the ANSI-256 renderer output. Colors are projected,
77	// so only text and wide-cell structure are compared.
78	replay256 *vt.Screen
79	// mirror reproduces the vev daemon consumer: one core.Frame reused and
80	// mutated in place between draws, so renderer shadows must not alias it.
81	mirror core.Frame
82	// hashes holds one digest per observable concern, so an intentional
83	// change to one layer (for example ANSI bytes) leaves the others'
84	// golden columns untouched and auditable.
85	hashes map[string]hash.Hash
86	dump   *bufio.Writer
87	events []string
88}
89
90// oracleConcerns are the golden columns, in output order. "input" covers the
91// generated workload and VT callbacks; the others cover one layer each.
92var oracleConcerns = []string{"input", "vt", "graphics", "history", "ansi", "ansi256", "html"}
93
94func (o *oracleRun) emit(concern, format string, args ...any) {
95	line := fmt.Sprintf(format, args...)
96	h := o.hashes[concern]
97	if h == nil {
98		o.t.Fatalf("unknown oracle concern %q", concern)
99	}
100	h.Write([]byte(line))
101	h.Write([]byte{'\n'})
102	if o.dump != nil {
103		// bufio.Writer keeps the first error; the deferred Flush reports it.
104		_, _ = fmt.Fprintf(o.dump, "%s: %s\n", concern, line)
105	}
106}
107
108func (o *oracleRun) digest() string {
109	var b strings.Builder
110	for i, concern := range oracleConcerns {
111		if i > 0 {
112			b.WriteByte(' ')
113		}
114		b.WriteString(concern)
115		b.WriteByte('=')
116		b.WriteString(hex.EncodeToString(o.hashes[concern].Sum(nil))[:12])
117	}
118	return b.String()
119}
120
121func formatCell(c core.Cell) string {
122	return fmt.Sprintf("%q|%+v|%q|%q|%t", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
123}
124
125// formatRow is a compact exact digest of a row: every Cell field contributes.
126// Full text is only produced for dumps, where it is needed for diagnosis.
127func formatRow(row []core.Cell) string {
128	h := sha256.New()
129	var buf []byte
130	for _, c := range row {
131		buf = fmt.Appendf(buf[:0], "%d|%+v|%s|%s|%t;", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
132		h.Write(buf)
133	}
134	return hex.EncodeToString(h.Sum(nil)[:12])
135}
136
137func rowText(row []core.Cell) string {
138	var b strings.Builder
139	for _, c := range row {
140		if c.Continuation {
141			continue
142		}
143		if g := c.Payload.Grapheme(); g != "" {
144			b.WriteString(g)
145		} else if c.Rune != 0 {
146			b.WriteRune(c.Rune)
147		}
148	}
149	return b.String()
150}
151
152func (o *oracleRun) genChunk() []byte {
153	r := o.r
154	var b bytes.Buffer
155	for range 1 + r.IntN(6) {
156		switch op := r.IntN(100); {
157		case op < 22:
158			words := []string{"hello", "world", "vev", "ls -la", "❯", "foo/bar", "λx", "0123456789"}
159			for range 1 + r.IntN(8) {
160				b.WriteString(words[r.IntN(len(words))])
161				b.WriteByte(' ')
162			}
163			if r.IntN(2) == 0 {
164				b.WriteString("\r\n")
165			}
166		case op < 30:
167			uni := []string{"界", "🙂", "é", "e\u0301", "한국어", "👍🏽", "a\u200db", "ff"}
168			for range 1 + r.IntN(10) {
169				b.WriteString(uni[r.IntN(len(uni))])
170			}
171		case op < 42:
172			b.WriteString(randomSGR(r))
173		case op < 50:
174			fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(o.sc.rows+2), 1+r.IntN(o.sc.cols+2))
175		case op < 56:
176			seqs := []string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J", "\x1b[2J", "\x1b[3@", "\x1b[2P", "\x1b[L", "\x1b[2M", "\x1b[4X", "\x1b[S", "\x1b[2T", "\x1bM", "\x1bD", "\x1bE", "\t", "\b"}
177			b.WriteString(seqs[r.IntN(len(seqs))])
178		case op < 64:
179			for range 1 + r.IntN(3*o.sc.rows) {
180				fmt.Fprintf(&b, "line %d %s\r\n", r.IntN(1000), strings.Repeat("=", r.IntN(o.sc.cols+5)))
181			}
182		case op < 68:
183			top := 1 + r.IntN(o.sc.rows)
184			bottom := top + r.IntN(o.sc.rows-top+1)
185			fmt.Fprintf(&b, "\x1b[%d;%dr\x1b[%d;1H", top, bottom, bottom)
186			for range 1 + r.IntN(6) {
187				b.WriteString("region\n")
188			}
189			if r.IntN(2) == 0 {
190				b.WriteString("\x1b[r")
191			}
192		case op < 71:
193			if r.IntN(2) == 0 {
194				b.WriteString("\x1b[?1049h")
195			} else {
196				b.WriteString("\x1b[?1049l")
197			}
198		case op < 74:
199			fmt.Fprintf(&b, "\x1b]8;;https://example.invalid/%d\x1b\\link\x1b]8;;\x1b\\", r.IntN(9))
200		case op < 76:
201			fmt.Fprintf(&b, "\x1b]0;title %d\x07", r.IntN(99))
202		case op < 81:
203			b.WriteString(randomKitty(r))
204		case op < 84:
205			queries := []string{"\x1b[6n", "\x1b[c", "\x1b[?2004$p", "\x1b[5n"}
206			b.WriteString(queries[r.IntN(len(queries))])
207		case op < 86:
208			if r.IntN(2) == 0 {
209				b.WriteString("\x1b[?7l")
210			} else {
211				b.WriteString("\x1b[?7h")
212			}
213		case op < 91:
214			// High-cardinality truecolor burst.
215			for range 1 + r.IntN(40) {
216				fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm#", r.IntN(256), r.IntN(256), r.IntN(256))
217			}
218			b.WriteString("\x1b[0m")
219		case op < 94:
220			// Styled blanks and erases inherit the current background.
221			fmt.Fprintf(&b, "\x1b[48;5;%dm\x1b[K   \x1b[0m", r.IntN(256))
222		default:
223			fmt.Fprintf(&b, "\r\x1b[K❯ abc\x1b[90m suggestion\x1b[39m\r\x1b[%dC", r.IntN(8))
224		}
225	}
226	return b.Bytes()
227}
228
229func randomSGR(r *rand.Rand) string {
230	parts := []string{"0", "1", "2", "3", "4", "4:3", "5", "7", "9", "22", "23", "24", "27", "39", "49", "59"}
231	switch r.IntN(6) {
232	case 0:
233		return fmt.Sprintf("\x1b[%dm", 30+r.IntN(8))
234	case 1:
235		return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", r.IntN(256), r.IntN(256))
236	case 2:
237		return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r.IntN(256), r.IntN(256), r.IntN(256))
238	case 3:
239		return fmt.Sprintf("\x1b[58;2;%d;%d;%d;4:%dm", r.IntN(256), r.IntN(256), r.IntN(256), r.IntN(6))
240	default:
241		return "\x1b[" + parts[r.IntN(len(parts))] + "m"
242	}
243}
244
245func randomKitty(r *rand.Rand) string {
246	switch r.IntN(4) {
247	case 0:
248		w, h := 1+r.IntN(4), 1+r.IntN(4)
249		pixels := make([]byte, w*h*4)
250		for i := range pixels {
251			pixels[i] = byte(r.IntN(256))
252		}
253		enc := encodeBase64(pixels, r.IntN(2) == 0)
254		return fmt.Sprintf("\x1b_Ga=T,i=%d,f=32,s=%d,v=%d,c=%d,r=%d,q=%d;%s\x1b\\", 1+r.IntN(4), w, h, 1+r.IntN(3), 1+r.IntN(2), r.IntN(3), enc)
255	case 1:
256		// Chunked transmission.
257		pixels := make([]byte, 4*4*4)
258		for i := range pixels {
259			pixels[i] = byte(r.IntN(256))
260		}
261		enc := encodeBase64(pixels, true)
262		mid := (len(enc) / 8) * 4
263		return fmt.Sprintf("\x1b_Ga=t,i=%d,f=32,s=4,v=4,m=1;%s\x1b\\\x1b_Gm=0;%s\x1b\\\x1b_Ga=p,i=%d\x1b\\", 5+r.IntN(3), enc[:mid], enc[mid:], 5+r.IntN(3))
264	case 2:
265		return fmt.Sprintf("\x1b_Ga=d,d=%s\x1b\\", []string{"a", "A", "i", "I"}[r.IntN(4)])
266	default:
267		// Invalid base64 must be rejected identically.
268		return "\x1b_Ga=T,f=32,s=1,v=1;!!!\x1b\\"
269	}
270}
271
272func encodeBase64(data []byte, padded bool) string {
273	const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
274	var b strings.Builder
275	for i := 0; i < len(data); i += 3 {
276		var chunk [3]byte
277		n := copy(chunk[:], data[i:])
278		v := uint(chunk[0])<<16 | uint(chunk[1])<<8 | uint(chunk[2])
279		for j := range 4 {
280			if j <= n {
281				b.WriteByte(alphabet[(v>>(18-6*j))&63])
282			} else if padded {
283				b.WriteByte('=')
284			}
285		}
286	}
287	return b.String()
288}
289
290func (o *oracleRun) write(data []byte) {
291	if o.sc.byteSplit {
292		for i := range data {
293			o.screen.Write(data[i : i+1 : i+1])
294		}
295		return
296	}
297	for len(data) > 0 {
298		n := 1 + o.r.IntN(len(data))
299		if o.r.IntN(3) != 0 {
300			n = len(data)
301		}
302		o.screen.Write(data[:n:n])
303		data = data[n:]
304	}
305}
306
307func (o *oracleRun) maybeResize(step int) {
308	if o.r.IntN(40) != 0 {
309		return
310	}
311	cols, rows := 8+o.r.IntN(110), 3+o.r.IntN(40)
312	o.emit("input", "step %d resize %dx%d", step, cols, rows)
313	o.screen.Resize(cols, rows)
314}
315
316func (o *oracleRun) checkScreen() {
317	s := o.screen
318	o.emit("vt", "screen %dx%d cursor=%d,%d vis=%t title=%q alt=%t", s.Columns(), s.Rows(), s.CursorRow(), s.CursorCol(), s.CursorVisible(), s.TerminalTitle(), s.AltScreenActive())
319	snap := s.Snapshot()
320	o.emit("vt", "modes %+v cursor %+v next=%d", snap.Modes(), snap.Cursor(), snap.NextRowID())
321	for y := range s.Rows() {
322		o.emit("vt", "row %d id=%d bound=%+v %s %q", y, s.RowID(y), snap.Bound(y), formatRow(s.RowCells(y)), rowText(s.RowCells(y)))
323		snapRow := snap.Row(y)
324		if formatRow(snapRow) != formatRow(s.RowCells(y)) {
325			o.t.Fatalf("seed %d: snapshot row %d differs from live row", o.sc.seed, y)
326		}
327	}
328	o.emit("vt", "bounds %+v", s.LineBounds())
329}
330
331func (o *oracleRun) checkGraphics() {
332	g := o.screen.GraphicsSnapshot()
333	if g == nil {
334		o.emit("graphics", "graphics nil")
335		return
336	}
337	o.emit("graphics", "graphics gen=%d usage=%+v", g.Generation(), g.Usage())
338	for _, a := range g.Assets() {
339		blob := a.Blob()
340		sum := sha256.Sum256(blob.Encoded)
341		o.emit("graphics", "asset fmt=%v %dx%d px=%d sha=%x", blob.Format, blob.Width, blob.Height, blob.DecodedPixels, sum[:8])
342	}
343	for _, p := range g.Placements() {
344		o.emit("graphics", "placement %+v", p.Spec())
345	}
346	o.emit("graphics", "fragments %+v", g.VisibleCellFragments(graphics.CellRect{Width: int64(o.screen.Columns()), Height: int64(o.screen.Rows())}))
347}
348
349func (o *oracleRun) checkHistory(deep bool) {
350	h := o.screen.History()
351	if h == nil {
352		return
353	}
354	o.emit("history", "history len=%d cells=%d bytes=%d next=%d stats=%+v", h.Len(), h.Cells(), h.LogicalBytes(), h.NextRowID(), h.CompressionStats())
355	if !deep {
356		return
357	}
358	snapView := h.SnapshotView()
359	tail, err := vt.MarshalHistoryTail(snapView)
360	if err != nil {
361		o.t.Fatalf("seed %d: MarshalHistoryTail: %v", o.sc.seed, err)
362	}
363	sum := sha256.Sum256(tail)
364	o.emit("history", "snapview len=%d chunks=%d tail=%x", snapView.Len(), snapView.ChunkCount(), sum)
365	var sealed [][]byte
366	for i := range snapView.ChunkCount() {
367		blob, err := vt.MarshalHistoryChunk(snapView.Chunk(i))
368		if err != nil {
369			o.t.Fatalf("seed %d: MarshalHistoryChunk: %v", o.sc.seed, err)
370		}
371		sum := sha256.Sum256(blob)
372		o.emit("history", "chunk %d %x", i, sum)
373		sealed = append(sealed, blob)
374	}
375	view := h.View()
376	var rows []string
377	var texts []string
378	if err := view.Range(func(row []core.Cell) bool {
379		rows = append(rows, formatRow(row))
380		texts = append(texts, rowText(row))
381		return true
382	}); err != nil {
383		o.t.Fatalf("seed %d: history Range: %v", o.sc.seed, err)
384	}
385	for i, row := range rows {
386		o.emit("history", "hrow %d id=%d bound=%+v %s %q", i, view.RowID(i), view.Bound(i), row, texts[i])
387		if got := formatRow(view.Row(i)); got != row {
388			o.t.Fatalf("seed %d: history Row(%d) differs from Range", o.sc.seed, i)
389		}
390		dst := make([]core.Cell, view.RowWidth(i))
391		view.CopyRow(i, dst)
392		if formatRow(dst) != row {
393			o.t.Fatalf("seed %d: history CopyRow(%d) differs from Range", o.sc.seed, i)
394		}
395	}
396	restored, err := vt.HistoryFromBlobs(h.Limits(), sealed, tail)
397	if err != nil {
398		o.t.Fatalf("seed %d: HistoryFromBlobs: %v", o.sc.seed, err)
399	}
400	rview := restored.View()
401	if rview.Len() != view.Len() || restored.NextRowID() != h.NextRowID() {
402		o.t.Fatalf("seed %d: restored history len=%d next=%d want len=%d next=%d", o.sc.seed, rview.Len(), restored.NextRowID(), view.Len(), h.NextRowID())
403	}
404	for i, row := range rows {
405		if formatRow(rview.Row(i)) != row || rview.RowID(i) != view.RowID(i) || rview.Bound(i) != view.Bound(i) {
406			o.t.Fatalf("seed %d: restored history row %d differs", o.sc.seed, i)
407		}
408	}
409	transcript, err := o.screen.RecoveryTranscriptSnapshot().Marshal()
410	if err != nil {
411		o.t.Fatalf("seed %d: recovery transcript: %v", o.sc.seed, err)
412	}
413	tsum := sha256.Sum256(transcript)
414	o.emit("history", "transcript %x", tsum)
415	recovered, err := vt.NewScreenWithRecoveryTranscript(o.screen.Columns(), o.screen.Rows(), h.Limits(), sealed, tail, transcript)
416	if err != nil {
417		o.t.Fatalf("seed %d: NewScreenWithRecoveryTranscript: %v", o.sc.seed, err)
418	}
419	rh := recovered.History()
420	o.emit("history", "recovered len=%d next=%d", rh.Len(), rh.NextRowID())
421	if rh.Len() > 0 {
422		rv := rh.View()
423		o.emit("history", "recovered last %s", formatRow(rv.Row(rv.Len()-1)))
424	}
425}
426
427func (o *oracleRun) render(step int) {
428	s := o.screen
429	damage := append([]core.Damage(nil), s.Damage()...)
430	capture := s.CaptureDamage()
431	if len(capture.Damage) != len(damage) {
432		o.t.Fatalf("seed %d: CaptureDamage length %d want %d", o.sc.seed, len(capture.Damage), len(damage))
433	}
434	o.emit("vt", "damage %+v", damage)
435
436	if o.mirror.Width != s.Columns() || o.mirror.Height != s.Rows() {
437		o.mirror = core.NewFrame(s.Columns(), s.Rows())
438	}
439	for y := range s.Rows() {
440		for x := range s.Columns() {
441			o.mirror.Set(x, y, s.Cell(x, y))
442		}
443	}
444	var source core.CellSource = o.mirror
445	switch o.sc.seed % 3 {
446	case 1:
447		source = &o.mirror
448	case 2:
449		source = s // generic CellSource path
450	}
451	prepared, err := o.ansiR.Prepare(source, damage, false)
452	if err != nil {
453		o.t.Fatalf("seed %d step %d: ansi Prepare: %v", o.sc.seed, step, err)
454	}
455	out := prepared.Bytes()
456	o.emit("ansi", "ansi %q", out)
457	if o.r.IntN(7) == 0 {
458		// Discarded draw: renderer must keep its committed shadow and damage
459		// must remain pending for the next draw.
460		o.emit("ansi", "ansi discard")
461	} else {
462		prepared.Commit()
463		o.replay = o.applyReplay(o.replay, out, step, true)
464		if !s.AcknowledgeDamage(capture.Generation) {
465			o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)
466		}
467	}
468	other, err := o.ansi256.Draw(source, damage)
469	if err != nil {
470		o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)
471	}
472	o.emit("ansi256", "ansi256 %q", other)
473	o.replay256 = o.applyReplay(o.replay256, other, step, false)
474
475	snap := s.Snapshot()
476	cur := snap.Cursor()
477	var htmlSource core.CellSource = o.mirror
478	if o.sc.seed%3 == 1 {
479		htmlSource = snap
480	}
481	hp, err := o.htmlR.Prepare(htmlSource, damage, false, html.Cursor{Row: cur.Row, Column: cur.Col, Visible: cur.Visible, Style: html.CursorStyle(cur.Style), StyleSet: cur.StyleSet})
482	if err != nil {
483		o.emit("html", "html err %v", err)
484		return
485	}
486	o.emit("html", "html %s", hp.JSON())
487	if o.r.IntN(6) == 0 {
488		if err := hp.Abort(); err != nil {
489			o.t.Fatalf("seed %d: html abort: %v", o.sc.seed, err)
490		}
491		o.emit("html", "html abort")
492	} else if err := hp.Commit(); err != nil {
493		o.t.Fatalf("seed %d: html commit: %v", o.sc.seed, err)
494	}
495}
496
497// applyReplay feeds committed ANSI output to an independent VT and requires
498// the visible grid to match the source. This semantic check does not depend on
499// the golden file.
500func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen {
501	s := o.screen
502	if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() {
503		replay = vt.NewScreen(s.Columns(), s.Rows())
504	}
505	replay.Write(out)
506	for y := range s.Rows() {
507		for x := range s.Columns() {
508			want, got := s.Cell(x, y), replay.Cell(x, y)
509			if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) {
510				o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want))
511			}
512		}
513	}
514	return replay
515}
516
517func runOracleScenario(t *testing.T, idx int) string {
518	sc := oracleScenarioFor(idx)
519	o := &oracleRun{t: t, sc: sc, r: rand.New(rand.NewPCG(sc.seed, 0xdecaf)), hashes: map[string]hash.Hash{}}
520	for _, concern := range oracleConcerns {
521		o.hashes[concern] = sha256.New()
522	}
523	if *oracleDump != "" {
524		if err := os.MkdirAll(*oracleDump, 0o755); err != nil {
525			t.Fatal(err)
526		}
527		f, err := os.Create(filepath.Join(*oracleDump, fmt.Sprintf("scenario-%02d.txt", idx)))
528		if err != nil {
529			t.Fatal(err)
530		}
531		o.dump = bufio.NewWriter(f)
532		defer func() {
533			if err := errors.Join(o.dump.Flush(), f.Close()); err != nil {
534				t.Errorf("write oracle dump: %v", err)
535			}
536		}()
537	}
538	if sc.history != nil {
539		o.screen = vt.NewScreenWithHistory(sc.cols, sc.rows, *sc.history)
540	} else {
541		o.screen = vt.NewScreen(sc.cols, sc.rows)
542	}
543	o.screen.OnResponse = func(b []byte) { o.events = append(o.events, fmt.Sprintf("response %q", b)) }
544	o.screen.OnBell = func() { o.events = append(o.events, "bell") }
545	if sc.evictHook {
546		// The callback owns each row: retain it, verify it is never rewritten by
547		// later evictions, then scribble on it to prove VT kept its own copy.
548		var retained []core.Cell
549		var retainedDigest string
550		o.screen.OnLineEvicted = func(row []core.Cell) {
551			if retained != nil && formatRow(retained) != retainedDigest {
552				t.Fatalf("seed %d: evicted row storage was reused after the callback", sc.seed)
553			}
554			digest := formatRow(row)
555			o.events = append(o.events, "evicted "+digest+" "+rowText(row))
556			if prev := retained; prev != nil {
557				for i := range prev {
558					prev[i] = core.Cell{Rune: 'X'}
559				}
560			}
561			retained, retainedDigest = row, digest
562		}
563	}
564	o.ansiR = ansi.New(ansi.Capabilities{SynchronizedOutput: idx%2 == 0})
565	o.ansi256 = ansi.NewWithColorProfile(ansi.Capabilities{}, ansi.ColorProfileANSI256)
566	var err error
567	o.htmlR, err = html.New(html.Options{})
568	if err != nil {
569		t.Fatal(err)
570	}
571	var historyConfig vt.HistoryConfig
572	if sc.history != nil {
573		historyConfig = *sc.history
574	}
575	o.emit("input", "scenario %d %dx%d history=%+v byteSplit=%t evictHook=%t", idx, sc.cols, sc.rows, historyConfig, sc.byteSplit, sc.evictHook)
576
577	var digests []string
578	for step := range oracleSteps {
579		o.maybeResize(step)
580		chunk := o.genChunk()
581		o.emit("input", "step %d write %q", step, chunk)
582		o.write(chunk)
583		for _, e := range o.events {
584			o.emit("input", "%s", e)
585		}
586		o.events = o.events[:0]
587		o.render(step)
588		if step%oracleCheckEvery == oracleCheckEvery-1 {
589			o.checkScreen()
590			o.checkGraphics()
591			o.checkHistory(step%(2*oracleCheckEvery) == 2*oracleCheckEvery-1)
592			digests = append(digests, fmt.Sprintf("%02d/%03d %s", idx, step, o.digest()))
593		}
594	}
595	return strings.Join(digests, "\n")
596}
597
598// TestDifferentialOracle compares every checkpoint against
599// testdata/oracle/golden.txt. Each golden line holds one digest per concern
600// (see oracleConcerns), so an intentional output change shows which layers
601// moved. Regenerate with -oracle.update only for a deliberate behavior change,
602// on a full run, and justify the changed columns; diagnose a divergence by
603// running -oracle.dump=DIR on both trees and diffing the scenario transcript.
604// Under -race only every sixth scenario runs: scenarios are single-goroutine,
605// so the detector adds cost without coverage.
606func TestDifferentialOracle(t *testing.T) {
607	if testing.Short() {
608		t.Skip("differential oracle skipped in -short mode")
609	}
610	got := make([]string, oracleScenarios)
611	t.Run("scenarios", func(t *testing.T) {
612		for i := range oracleScenarios {
613			t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
614				if raceEnabled && i%6 != 0 {
615					t.Skip("reduced oracle under -race")
616				}
617				t.Parallel()
618				got[i] = runOracleScenario(t, i)
619			})
620		}
621	})
622	if t.Failed() {
623		return
624	}
625	ran := 0
626	for _, digests := range got {
627		if digests != "" {
628			ran++
629		}
630	}
631	if *oracleUpdate {
632		if ran != oracleScenarios {
633			t.Fatalf("-oracle.update needs every scenario; only %d of %d ran (remove -run filters and -race)", ran, oracleScenarios)
634		}
635		if err := os.MkdirAll(filepath.Dir(oracleGolden), 0o755); err != nil {
636			t.Fatal(err)
637		}
638		if err := os.WriteFile(oracleGolden, []byte(strings.Join(got, "\n")+"\n"), 0o644); err != nil {
639			t.Fatal(err)
640		}
641		return
642	}
643	golden, err := os.ReadFile(oracleGolden)
644	if err != nil {
645		t.Fatalf("read golden (run with -oracle.update on a trusted baseline): %v", err)
646	}
647	want := map[string][]string{}
648	for _, line := range strings.Split(strings.TrimSpace(string(golden)), "\n") {
649		if len(line) < 3 {
650			t.Fatalf("malformed golden line %q (regenerate with -oracle.update on a trusted baseline)", line)
651		}
652		want[line[:2]] = append(want[line[:2]], line)
653	}
654	for i, digests := range got {
655		if digests == "" {
656			continue
657		}
658		compareOracleScenario(t, fmt.Sprintf("%02d", i), strings.Split(digests, "\n"), want[fmt.Sprintf("%02d", i)])
659	}
660}
661
662func compareOracleScenario(t *testing.T, id string, got, want []string) {
663	t.Helper()
664	for i := range min(len(got), len(want)) {
665		if got[i] == want[i] {
666			continue
667		}
668		gotFields, wantFields := strings.Fields(got[i]), strings.Fields(want[i])
669		var moved []string
670		for j := 1; j < min(len(gotFields), len(wantFields)); j++ {
671			if gotFields[j] != wantFields[j] {
672				moved = append(moved, strings.SplitN(gotFields[j], "=", 2)[0])
673			}
674		}
675		t.Fatalf("scenario %s diverged at checkpoint %s in %v\n got  %s\n want %s\nrerun with -oracle.dump=DIR on both trees and diff the transcript", id, gotFields[0], moved, got[i], want[i])
676	}
677	if len(got) != len(want) {
678		t.Fatalf("scenario %s checkpoint count differs: got %d want %d", id, len(got), len(want))
679	}
680}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close