From 8911fc09e941df284736fa0bf5f11c84d6fc10c6 Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Thu, 29 Oct 2026 18:15:11 +0100 Subject: [PATCH] test: check the limits, the PR checks, the config names and the log - Black-box tests for Markdown depth and size, tall lines, too many files, large files, PR checks past 100, the setting names, and a private log with no typed text. - Library tests for long lines and the highlighter time limits. --- diff/caps_test.go | 57 ++++++ internal/diffview/caps_test.go | 199 +++++++++++++++++++ internal/diffview/norace_test.go | 6 + internal/diffview/race_test.go | 6 + test/caps_test.go | 315 +++++++++++++++++++++++++++++++ test/checks_test.go | 37 ++++ test/config_test.go | 159 ++++++++++++++++ test/limits_test.go | 144 ++++++++++++++ test/log_test.go | 52 +++++ 9 files changed, 975 insertions(+) create mode 100644 diff/caps_test.go create mode 100644 internal/diffview/caps_test.go create mode 100644 internal/diffview/norace_test.go create mode 100644 internal/diffview/race_test.go create mode 100644 test/caps_test.go create mode 100644 test/checks_test.go create mode 100644 test/config_test.go create mode 100644 test/limits_test.go create mode 100644 test/log_test.go diff --git a/diff/caps_test.go b/diff/caps_test.go new file mode 100644 index 0000000..c8cb949 --- /dev/null +++ b/diff/caps_test.go @@ -0,0 +1,57 @@ +package diff_test + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" +) + +// longLine returns a line of n bytes of Java keywords and words, which +// the Java lexer is slow on. +func longLine(n int) string { + return strings.Repeat("public static lorem ipsum ", n/26+1)[:n] +} + +// keywordColour is the escape sequence of a Java keyword. +const keywordColour = "\x1b[35m" + +// TestLongLinePlain checks that Render and RenderFile draw a line too long +// to highlight without colours, and the short lines around it with them, +// with and without wrapping. Highlighting the line would take about 30 s. +func TestLongLinePlain(t *testing.T) { + t.Parallel() + long := longLine(100_000) + src := "class A {}\n" + long + "\nclass B {}\n" + for _, wrap := range []bool{false, true} { + o := diff.Options{Width: 80, Wrap: wrap} + renders := map[string]*diff.Diff{ + "Render": diff.Render("A.java", "", src, o), + "RenderFile": diff.RenderFile("A.java", src, fileOptions(o)), + } + for name, d := range renders { + var shown []string + for i := range len(d.Rows) { + if d.Rows[i].New == 2 { + shown = append(shown, d.Line(i)) + } + } + if len(shown) == 0 { + t.Fatalf("%s, wrap %v: no rows for the long line", name, wrap) + } + if wrap && !strings.Contains(ansi.Strip(strings.Join(shown, "")), long[len(long)-40:]) { + t.Errorf("%s, wrap %v: the rows do not show the end of the long line", name, wrap) + } + for _, l := range shown { + if strings.Contains(l, keywordColour) { + t.Fatalf("%s, wrap %v: long line row %.60q… has colours", name, wrap, l) + } + } + if last := d.Line(len(d.Rows) - 1); !strings.Contains(last, keywordColour) { + t.Errorf("%s, wrap %v: last row %q, want it highlighted", name, wrap, last) + } + } + } +} diff --git a/internal/diffview/caps_test.go b/internal/diffview/caps_test.go new file mode 100644 index 0000000..8caefa9 --- /dev/null +++ b/internal/diffview/caps_test.go @@ -0,0 +1,199 @@ +package diffview_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/internal/diffview" +) + +// newHighlighter returns a Highlighter for src at path that escapes the +// text of tokens, as RenderFile does. +func newHighlighter(path, src string) *diffview.Highlighter { + return diffview.NewHighlighter(path, src, lipgloss.NoColor{}, escapeLines, escapeLines) +} + +// escapeLines escapes each line of s. +func escapeLines(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = diffview.Escape(l) + } + return strings.Join(lines, "\n") +} + +// wait waits until h has highlighted every line, as long as nothing +// calls Stop. +func wait(t *testing.T, h *diffview.Highlighter) { + t.Helper() + for deadline := time.Now().Add(time.Minute); h.Busy(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the highlighting runs for a minute") + } + } +} + +// coloured reports whether s contains an escape sequence. +func coloured(s string) bool { return strings.Contains(s, "\x1b[") } + +// TestHighlighterLongLine checks that a Highlighter draws a line too long +// to lex plain and escaped, and still highlights the other lines. +func TestHighlighterLongLine(t *testing.T) { + t.Parallel() + long := "var s = \"\x07" + strings.Repeat("x", 5000) + "\"" + h := newHighlighter("x.go", "package a\n"+long+"\nfunc f() {}\n") + wait(t, h) + if l, _, _ := h.Line(0); !coloured(l) { + t.Errorf("line 0 = %q, want colours", l) + } + if l, _, _ := h.Line(1); l != diffview.Escape(long) { + t.Errorf("line 1 = %.40q…, want the line plain and escaped", l) + } + if l, _, _ := h.Line(2); !coloured(l) { + t.Errorf("line 2 = %q, want colours", l) + } +} + +// TestHighlighterLargeFile checks that a Highlighter still highlights the +// lines of a file too large to lex as a whole. +func TestHighlighterLargeFile(t *testing.T) { + t.Parallel() + if raceOn { + t.Skip("the race detector makes each line miss its highlighting time limit") + } + comment := "// " + strings.Repeat("x", 1400) + src := "package a\n" + strings.Repeat(comment+"\n", 400) + "func f() {}\n" // about 560 KB + h := newHighlighter("x.go", src) + wait(t, h) + for i, want := range map[int]string{0: "package a", 1: comment, 401: "func f() {}"} { + if l, _, _ := h.Line(i); !coloured(l) || ansi.Strip(l) != want { + t.Errorf("line %d = %.40q…, want it highlighted", i, l) + } + } +} + +// TestHighlighterSlow checks that Line does not wait for a lexer that is +// slow, and returns the line plain instead: one screen of such lines takes +// far less time than the lexing. +func TestHighlighterSlow(t *testing.T) { + t.Parallel() + // The Java lexer takes about a second on these lines. + words := "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor\n" + src := strings.Repeat(words, 300) + "class A {}\n" + h := newHighlighter("x.java", src) + defer h.Stop() + start := time.Now() + l, ok, final := h.Line(300) + if !ok || final || l != "class A {}" { + t.Errorf("Line(300) = %q, %v, %v, want the line plain and not final", l, ok, final) + } + for i := 299; i > 259; i-- { + if _, _, final := h.Line(i); final { + t.Errorf("Line(%d) is final before lexing passes line 300", i) + } + } + // The race detector slows the lexer down too much for the time check. + if d := time.Since(start); d > 250*time.Millisecond && !raceOn { + t.Errorf("41 lines took %v, want Line to give up waiting sooner", d) + } +} + +// TestHighlighterStop checks that Busy reports true while lexing goes on, +// and that Stop ends the lexing of a source that is slow to lex within a +// second, rather than after its time budget. +func TestHighlighterStop(t *testing.T) { + t.Parallel() + h := newHighlighter("x.java", slowJava()) + if !h.Busy() { + t.Error("Busy = false at the start, want true while lexing") + } + h.Stop() + start := time.Now() + wait(t, h) + // The race detector slows the lexer down too much for the time check. + if d := time.Since(start); d > time.Second && !raceOn { + t.Errorf("lexing runs %v after Stop, want at most a second", d) + } +} + +// TestHighlighterTypicalFile checks that a typical Go file of 100 KB is +// coloured to the end: no line of it misses a time limit. +func TestHighlighterTypicalFile(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString("package a\n\nimport \"fmt\"\n\n") + for i := 0; b.Len() < 100<<10; i++ { + fmt.Fprintf(&b, "// F%d prints its argument.\nfunc F%d(x int) string {\n\treturn fmt.Sprintf(\"%%d: %%s\", x, \"text\")\n}\n\n", i, i) + } + src := b.String() + h := newHighlighter("a.go", src) + defer h.Stop() + wait(t, h) + lines := []int{0, 2} + if !raceOn { // the race detector makes lexing slower than its time limit + lines = append(lines, strings.Count(src[:strings.LastIndex(src, "func ")], "\n")) + } + for _, i := range lines { + if l, _, final := h.Line(i); !final || !coloured(l) { + t.Errorf("line %d = %q, final %v, want it highlighted", i, l, final) + } + } +} + +// TestWrapLine checks that WrapLine breaks a coloured line where WrapParts +// breaks its plain text, even when colours split a grapheme cluster. +func TestWrapLine(t *testing.T) { + t.Parallel() + family := "👨\u200d👩\u200d👧" // one cluster of five runes + plain := strings.Repeat("ab "+family+" cd ", 20) + red, reset := "\x1b[31m", "\x1b[m" + // Colour each rune of the cluster on its own, as a lexer can. + code := strings.ReplaceAll(plain, family, red+"👨"+reset+"\u200d"+red+"👩"+reset+"\u200d👧") + code = strings.ReplaceAll(code, "ab", red+"ab"+reset) + for _, width := range []int{1, 3, 7, 10, 25} { + want := diffview.WrapParts(plain, width) + got := diffview.WrapLine(plain, code, width) + if len(got) != len(want) { + t.Fatalf("width %d: %d parts, want %d", width, len(got), len(want)) + } + for k := range got { + if ansi.Strip(got[k]) != want[k] { + t.Fatalf("width %d: part %d = %q, want the text %q", width, k, got[k], want[k]) + } + } + if !coloured(strings.Join(got, "")) { + t.Errorf("width %d: the parts have no colours", width) + } + } + // A code line that does not match plain gives the plain parts. + if got := diffview.WrapLine(plain, "other", 10); strings.Join(got, "") != strings.Join(diffview.WrapParts(plain, 10), "") { + t.Errorf("WrapLine with other text = %q, want the plain parts", got) + } +} + +// slowJava returns about 100 KB of lines of words, on which the Java +// lexer takes about 30 s when it lexes them as a whole. +func slowJava() string { + return strings.Repeat(strings.Repeat("lorem ipsum dolor sit amet ", 7)+"\n", 500) + "class A {}\n" +} + +// TestHighlighterBudget checks that a Highlighter stops lexing a source +// that is slow to lex after its time budget, and draws the rest plain. +func TestHighlighterBudget(t *testing.T) { + t.Parallel() + src := slowJava() + h := newHighlighter("x.java", src) + start := time.Now() + wait(t, h) + if d := time.Since(start); d > 10*time.Second { + t.Errorf("lexing took %v, want it to stop after its budget", d) + } + if l, ok, final := h.Line(500); !ok || !final || l != "class A {}" { + t.Errorf("Line(500) = %q, %v, %v, want the line plain and final", l, ok, final) + } +} diff --git a/internal/diffview/norace_test.go b/internal/diffview/norace_test.go new file mode 100644 index 0000000..69e4014 --- /dev/null +++ b/internal/diffview/norace_test.go @@ -0,0 +1,6 @@ +//go:build !race + +package diffview_test + +// raceOn reports whether the tests run with the race detector. +const raceOn = false diff --git a/internal/diffview/race_test.go b/internal/diffview/race_test.go new file mode 100644 index 0000000..fc1ae21 --- /dev/null +++ b/internal/diffview/race_test.go @@ -0,0 +1,6 @@ +//go:build race + +package diffview_test + +// raceOn reports whether the tests run with the race detector. +const raceOn = true diff --git a/test/caps_test.go b/test/caps_test.go new file mode 100644 index 0000000..22ebb66 --- /dev/null +++ b/test/caps_test.go @@ -0,0 +1,315 @@ +package test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// markdownRepo returns a repository whose only change is README.md, set +// to text. +func markdownRepo(t *testing.T, text string) string { + t.Helper() + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "a.go", "long.txt") + testgit.WriteFile(t, repo, "README.md", text) + return repo +} + +// rendered is the flag that shows Markdown rendered from the start. +var rendered = []string{"-config", `{"markdown_view": "rendered"}`} + +// TestMarkdownTooLarge checks that a Markdown file too large to render +// shows as its source diff, with a note in the title. +func TestMarkdownTooLarge(t *testing.T) { + text := "# Big title\n\n" + strings.Repeat("Some *text* in a paragraph.\n\n", 20000) // about 600 KB + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + tm.waitFor("README.md", "markdown too large to render", "# Big title", "Some *text*") +} + +// TestMarkdownBlockTooLarge checks that a rendered Markdown file shows a +// block too large to render as its source, and that reference links +// still resolve in the other blocks. +func TestMarkdownBlockTooLarge(t *testing.T) { + table := "| a | b |\n|---|---|\n" + strings.Repeat("| *x* | y |\n", 7000) // about 80 KB + text := "# Title\n\nSee [the docs][Docs].\n\n[docs]: https://example.com/docs\n\n" + table + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + s := tm.waitFor("See the docs", "https://example.com/docs", "block too large to render", "| *x* | y |") + if strings.Contains(s, "# Title") || strings.Contains(s, "[docs]:") { + t.Errorf("the small blocks show as source. The screen shows:\n%s", s) + } +} + +// TestMarkdownTooDeep checks that rendered Markdown shows blocks whose +// quotes or lists nest very deeply as source, at start-up and at any +// width, instead of freezing while glamour renders them. +func TestMarkdownTooDeep(t *testing.T) { + text := "# Title\n\n" + strings.Repeat("> ", 100) + "deep quote\n\n" + + strings.Repeat("- ", 300) + "deep list\n\nAfter the *lists*.\n" + for _, width := range []uint16{40, 120, 300} { + t.Run(fmt.Sprintf("%d columns", width), func(t *testing.T) { + tm := start(t, markdownRepo(t, text), opts{args: rendered, width: width}) + s := tm.waitFor("Title", "nested too deeply", "> > >") + if strings.Contains(s, "# Title") { + t.Errorf("the heading shows as source. The screen shows:\n%s", s) + } + // At 40 columns, the source of the blocks fills more than the + // screen. + tm.keys(":", "$", "enter") + tm.waitFor("After the lists.", "- - -") + }) + } +} + +// nestedList returns a list nested depth levels deep, with each level +// indented to the text of the item above, which starts with marker. +func nestedList(marker string, depth int) string { + var b strings.Builder + for d := range depth { + fmt.Fprintf(&b, "%s%s item %d\n", strings.Repeat(" ", d*(len(marker)+1)), marker, d) + } + return b.String() +} + +// TestMarkdownDepth checks where rendered Markdown draws the line on +// nesting: deep but ordinary lists, quotes and code render, while blocks +// nested more than 12 deep, however they hide it, show as source with a +// note. +func TestMarkdownDepth(t *testing.T) { + const note = "nested too deeply" + fence := "```\n" + strings.Repeat(" ", 26) + "deep code 26\n" + + strings.Repeat(" ", 40) + "deep code 40\n```\n" + for _, c := range []struct { + name string + block string + deep bool // whether the block shows as source with the note + whole bool // whether the block takes in the whole file + shows []string // text the screen shows + hides []string // text the screen does not show + }{ + {name: "ordered list 9 deep", block: nestedList("1.", 9), shows: []string{"item 8"}}, + {name: "ordered list 12 deep", block: nestedList("1.", 12), shows: []string{"item 11"}}, + {name: "bullet list 12 deep", block: nestedList("-", 12), shows: []string{"item 11"}, hides: []string{"- item"}}, + {name: "quote 12 deep", block: strings.Repeat("> ", 12) + "deep quote\n", shows: []string{"deep quote"}, hides: []string{"> >"}}, + {name: "indented fenced code", block: fence, shows: []string{"deep code 26", "deep code 40"}, hides: []string{"```"}}, + {name: "fenced code in a list", block: "- item\n\n " + strings.ReplaceAll(fence, "\n", "\n "), shows: []string{"deep code 40"}, hides: []string{"```"}}, + {name: "indented code", block: " code\n" + strings.Repeat(" ", 34) + "deep code 30\n", shows: []string{"deep code 30"}}, + // Within a quote, a deep quote is code inside the fence. + {name: "quote in a fence in a quote", block: "> ```\n" + strings.Repeat("> ", 50) + "x\n", shows: []string{"> > >"}}, + + {name: "quote 13 deep", block: strings.Repeat("> ", 13) + "deep\n", deep: true, shows: []string{"> > >"}}, + {name: "quote 100 deep", block: strings.Repeat("> ", 100) + "deep\n", deep: true, shows: []string{"> > >"}}, + {name: "list 13 deep", block: strings.Repeat("- ", 13) + "deep\n", deep: true, shows: []string{"- - -"}}, + {name: "list 300 deep", block: strings.Repeat("- ", 300) + "deep\n", deep: true, shows: []string{"- - -"}}, + {name: "ordered list 13 deep", block: nestedList("1.", 13), deep: true, shows: []string{"1. item 12"}}, + {name: "mixed", block: strings.Repeat("> - 1. ", 30) + "deep\n", deep: true, shows: []string{"> - 1. > - 1."}}, + // A list item ends the code block of the item above, so the list + // that follows is not hidden in the fence. + {name: "list after an unclosed fence", block: "- ```\n" + nestedList("-", 13), deep: true, shows: []string{"- item 12"}}, + // Indented four columns, the fence is code and opens no block. + // Being indented, it joins the heading's block, and the fence takes + // in the rest of the file. + {name: "indented code is not a fence", block: " ```\n" + strings.Repeat("- ", 20) + "x\n", deep: true, whole: true, shows: []string{"- - -"}}, + } { + t.Run(c.name, func(t *testing.T) { + text := "# Title\n\n" + c.block + "\nAfter the *end*.\n" + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + after := "After the end." + if c.whole { + after = "After the *end*." + } + s := tm.waitFor(append([]string{"Title", after}, c.shows...)...) + if !c.whole && strings.Contains(s, "# Title") { + t.Errorf("the heading shows as source. The screen shows:\n%s", s) + } + if got := strings.Contains(s, note); got != c.deep { + t.Errorf("the screen shows %q: %v, want %v. The screen shows:\n%s", note, got, c.deep, s) + } + if hasAny(s, c.hides...) { + t.Errorf("the screen shows one of %q. It shows:\n%s", c.hides, s) + } + }) + } +} + +// TestMarkdownLinkDefsTooLarge checks that link definitions that many +// references repeat do not freeze rendered Markdown. A file that would +// take too much work shows as source with a note, and a long definition is +// not added to the blocks that refer to it. +func TestMarkdownLinkDefsTooLarge(t *testing.T) { + t.Run("many references", func(t *testing.T) { + var b strings.Builder + b.WriteString("# Refs\n\n") + for i := range 3000 { + fmt.Fprintf(&b, "[d%d]\n\n", i%3) + } + for i := range 3 { + fmt.Fprintf(&b, "[d%d]: https://example.com/%s\n", i, strings.Repeat("a", 1900)) + } + tm := start(t, markdownRepo(t, b.String()), opts{args: rendered}) + tm.waitFor("README.md", "markdown too large to render", "# Refs") + }) + t.Run("long definition", func(t *testing.T) { + text := "# Refs\n\n" + strings.Repeat("See [d].\n\n", 1000) + + "[d]: https://example.com/" + strings.Repeat("a", 60000) + "\n" + tm := start(t, markdownRepo(t, text), opts{args: rendered}) + s := tm.waitFor("Refs", "See [d].") + if strings.Contains(s, "# Refs") || strings.Contains(s, "aaaa") { + t.Errorf("the file shows as source or with the long link. The screen shows:\n%s", s) + } + }) +} + +// prRepo returns a repository with a GitHub remote and a directory of +// fake tools whose gh prints a pull request with the given body. +func prRepo(t *testing.T, body string) (repo, bin string) { + t.Helper() + repo = newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + data, err := json.Marshal(map[string]any{ + "number": 7, "title": "Hostile PR", "state": "OPEN", "url": "https://github.com/o/r/pull/7", + "body": body, "author": map[string]string{"login": "ann"}, + "headRefName": "feat", "baseRefName": "main", + }) + if err != nil { + t.Fatal(err) + } + bin = tools(t, map[string]string{"gh": `cat "${0%/*}/pr.json"` + "\n"}) + if err := os.WriteFile(filepath.Join(bin, "pr.json"), data, 0o600); err != nil { + t.Fatal(err) + } + return repo, bin +} + +// TestPRMarkdownTooHard checks that the PR tab shows a body that nests too +// deeply, or that repeats a long link definition too often, as source +// instead of freezing. +func TestPRMarkdownTooHard(t *testing.T) { + for name, c := range map[string]struct{ body, note string }{ + "deep quote": {strings.Repeat("> ", 100) + "deep", "text nested too deeply to render"}, + "deep list": {strings.Repeat("- ", 300) + "deep", "text nested too deeply to render"}, + "references": { + strings.Repeat("[d] ", 2000) + "\n\n[d]: https://example.com/" + strings.Repeat("a", 5000), + "text too large to render", + }, + } { + t.Run(name, func(t *testing.T) { + repo, bin := prRepo(t, c.body) + tm := start(t, repo, opts{path: bin}) + tm.keys("3") + tm.waitFor("Hostile PR #7", c.note) + }) + } +} + +// TestPRTooLarge checks that the PR tab cuts long comments, with a note +// to open the PR, and that thousands of
tags in them do not +// stop the tab from showing. +func TestPRTooLarge(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + hostile := strings.Repeat("
", 3600) // 65 KB + var comments []map[string]any + for i := range 20 { + comments = append(comments, map[string]any{ + "author": map[string]string{"login": fmt.Sprint("user", i)}, "body": hostile, + "createdAt": "2024-01-01T00:00:00Z", + }) + } + data, err := json.Marshal(map[string]any{ + "number": 7, "title": "Big PR", "state": "OPEN", "url": "https://github.com/o/r/pull/7", + "body": "The body.", "author": map[string]string{"login": "ann"}, + "headRefName": "feat", "baseRefName": "main", "comments": comments, + }) + if err != nil { + t.Fatal(err) + } + bin := tools(t, map[string]string{"gh": `cat "${0%/*}/pr.json"` + "\n"}) + if err := os.WriteFile(filepath.Join(bin, "pr.json"), data, 0o600); err != nil { + t.Fatal(err) + } + tm := start(t, repo, opts{path: bin}) + tm.keys("3") + s := tm.waitFor("Big PR #7", "The body.", "user0", "… truncated · o to open in browser") + if strings.Contains(s, "
") { + t.Errorf("the comments show their tags. The screen shows:\n%s", s) + } +} + +// TestTallLine checks that page down, half page down and down move +// through the rows of a wrapped line taller than the screen, and then on +// to the next line. +func TestTallLine(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "long.txt") + var b strings.Builder + b.WriteString("package a\n\n// START") + for i := range 4000 { + fmt.Fprintf(&b, " w%d", i) + } + b.WriteString(" THE-END\nvar next = 1\n") + testgit.WriteFile(t, repo, "a.go", b.String()) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{args: []string{"-log", log}}) + // A frame that is partly drawn has no footer, a gap in the words, or a + // blank last line where the terminal scrolled the old lines up. + whole := func(s string) bool { + last, _, _ := strings.Cut(lastBodyLine(s), "│") + return strings.Contains(s, "q quit") && wordsInOrder(s) && strings.TrimSpace(last) != "" + } + // The file list gets wider when the line counts arrive, which wraps + // the line again, so wait for them first. + s := tm.waitUntil(func(s string) bool { + return strings.Contains(s, "// START") && sameLine(s, "M a.go", "+2 -3") && whole(s) + }, "show the start of the line and its counts") + // The line takes about 200 rows. Each half page moves the view. + for i := 1; !strings.Contains(s, "THE-END"); i++ { + if i == 40 { + t.Fatalf("40 half pages do not reach the end of the line. The screen shows:\n%s", s) + } + tm.keys("ctrl+d") + // Wait until rexdiff has read the key, and then for its frame. + waitFile(t, log, fmt.Sprintf("%d keys", i), func(l string) bool { return strings.Count(l, "msg=key") == i }) + prev := s + s = tm.waitUntil(func(s string) bool { return s != prev && whole(s) }, "move the view") + } + if strings.Contains(s, "// START") || strings.Contains(s, "var next") { + t.Errorf("the screen shows more than the end of the line:\n%s", s) + } + tm.keys("pgup") + tm.waitGone("THE-END") + tm.keys("pgdown") + tm.waitFor("THE-END") + tm.keys("j") + tm.waitCursor("var next") +} + +// lastBodyLine returns the line above the footer of screen s. +func lastBodyLine(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n "), "\n") + if len(lines) < 2 { + return "" + } + return lines[len(lines)-2] +} + +// wordsInOrder reports whether the words w0, w1, … on screen s follow +// each other with no gap. +func wordsInOrder(s string) bool { + words := regexp.MustCompile(`\bw(\d+)\b`).FindAllStringSubmatch(s, -1) + for i := 1; i < len(words); i++ { + prev, _ := strconv.Atoi(words[i-1][1]) + n, _ := strconv.Atoi(words[i][1]) + if n != prev+1 { + return false + } + } + return len(words) > 0 +} diff --git a/test/checks_test.go b/test/checks_test.go new file mode 100644 index 0000000..3b9c839 --- /dev/null +++ b/test/checks_test.go @@ -0,0 +1,37 @@ +package test + +import ( + "regexp" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// TestPRManyChecks checks that the PR tab counts every check, not only the +// 100 that it keeps, and names the failures past the first 100 checks: +// gh reports 100 passing checks and then 150 failing ones. +func TestPRManyChecks(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + bin := tools(t, map[string]string{ + "gh": `printf '{"number": 7, "title": "Many checks", "state": "OPEN", "statusCheckRollup": [' +i=0 +while [ $i -lt 250 ]; do + c=SUCCESS + [ $i -ge 100 ] && c=FAILURE + [ $i -gt 0 ] && printf ',' + printf '{"name": "check%d", "conclusion": "%s"}' $i $c + i=$((i + 1)) +done +printf ']}' +`, + }) + // The terminal is tall enough for the whole list. + tm := start(t, repo, opts{path: bin, height: 120}) + tm.keys("3") + s := tm.waitFor("Many checks #7", "checks: ✓ 100 ✗ 150 ◌ 0", + "✗ check100", "✗ check199", "… 50 more failed checks") + if regexp.MustCompile(`check(99|200)\b`).MatchString(s) { + t.Errorf("the PR tab lists a passing check or more than 100 checks. The screen shows:\n%s", s) + } +} diff --git a/test/config_test.go b/test/config_test.go new file mode 100644 index 0000000..0b5350f --- /dev/null +++ b/test/config_test.go @@ -0,0 +1,159 @@ +package test + +import ( + "cmp" + "encoding/json/v2" + "regexp" + "strings" + "testing" +) + +// actionNames lists every action that the keybindings setting accepts. +// alias is a former rexdiff name that the setting also accepts, or "". +var actionNames = []struct{ name, alias string }{ + {"down", ""}, + {"up", ""}, + {"next-hunk", ""}, + {"prev-hunk", ""}, + {"next-file", ""}, + {"prev-file", ""}, + {"collapse", ""}, + {"expand", ""}, + {"page-up", ""}, + {"page-down", ""}, + {"half-up", ""}, + {"half-down", ""}, + {"focus", ""}, + {"scope-uncommitted", ""}, + {"scope-branch", ""}, + {"scope-last-turn", ""}, + {"scope-commits", ""}, + {"base-pick", ""}, + {"commit-pick", ""}, + {"tab-changes", ""}, + {"tab-all-files", ""}, + {"tab-pr", ""}, + {"wrap", ""}, + {"rendered", "preview"}, + {"split", ""}, + {"navigator-position", ""}, + {"navigator-hide", ""}, + {"navigator-grow", "list-wider"}, + {"navigator-shrink", "list-narrower"}, + {"select", ""}, + {"comment", ""}, + {"edit", ""}, + {"delete", ""}, + {"next-comment", ""}, + {"prev-comment", ""}, + {"comments", ""}, + {"search", ""}, + {"find", ""}, + {"goto-line", ""}, + {"keys", ""}, + {"send", ""}, + {"copy", ""}, + {"open-pr", ""}, + {"refresh", ""}, + {"quit", ""}, + {"quit-discard", ""}, +} + +// actionKeys holds the keys that TestConfigNames binds, one for each +// action in actionNames, in order. It leaves out U, which is always the +// update key. +const actionKeys = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTVWXYZ" + +// TestConfigNames checks that a config that uses the action names, or +// their aliases, binds every key and sets the position and width of the +// file list. It checks the key bindings through the help screen. +func TestConfigNames(t *testing.T) { + keyOf := map[string]string{} + for i, a := range actionNames { + keyOf[a.name] = actionKeys[i : i+1] + } + // Each help entry lists the keys followed by a label. Some labels + // cover two or three actions, such as "next/prev file". + k := func(names ...string) string { + keys := make([]string, len(names)) + for i, n := range names { + keys[i] = keyOf[n] + } + return strings.Join(keys, "/") + } + help := [][2]string{ + {k("down", "up"), "move"}, + {k("half-down", "half-up"), "half page"}, + {k("next-hunk", "prev-hunk"), "next/prev change"}, + {k("next-file", "prev-file"), "next/prev file"}, + {k("focus"), "switch pane"}, + {k("collapse", "expand"), "scroll sideways"}, + {k("goto-line"), "go to line"}, + {k("tab-changes", "tab-all-files", "tab-pr"), "changes/files/PR"}, + {k("scope-uncommitted"), "uncommitted"}, + {k("scope-branch"), "branch"}, + {k("scope-last-turn"), "last turn"}, + {k("scope-commits"), "commits"}, + {k("base-pick"), "pick base branch"}, + {k("commit-pick"), "pick commits"}, + {k("select"), "select lines"}, + {k("comment"), "comment"}, + {k("edit"), "edit comment/open editor"}, + {k("delete"), "delete comment"}, + {k("next-comment", "prev-comment"), "next/prev comment"}, + {k("comments"), "list comments"}, + {k("send"), "send to agent"}, + {k("copy"), "copy comments"}, + {k("search"), "search files and code"}, + {k("find"), "find in file"}, + {k("rendered"), "rendered markdown"}, + {k("split"), "side by side"}, + {k("wrap"), "wrap lines"}, + {k("navigator-position"), "move list"}, + {k("navigator-hide"), "hide list"}, + {k("navigator-grow", "navigator-shrink"), "resize list"}, + {k("open-pr"), "open PR in browser"}, + {k("refresh"), "refresh"}, + {k("keys"), "all keys"}, + {k("quit"), "quit"}, + } + var res []*regexp.Regexp + for _, h := range help { + res = append(res, regexp.MustCompile(`(^|\s)`+regexp.QuoteMeta(h[0])+` +`+regexp.QuoteMeta(h[1]))) + } + for _, names := range []string{"names", "aliases"} { + t.Run(names, func(t *testing.T) { + kb := map[string][]string{} + for _, a := range actionNames { + name := a.name + if names == "aliases" { + name = cmp.Or(a.alias, a.name) + } + kb[name] = []string{keyOf[a.name]} + } + b, err := json.Marshal(map[string]any{ + "keybindings": kb, "file_list": "left", "file_list_width": 50, "url_opener": "opener {url}", + }) + if err != nil { + t.Fatal(err) + } + tm := start(t, newRepo(t), opts{width: 220, args: []string{"-config", string(b)}}) + s := tm.waitFor("Changes (2)") + if c := column(s, "Changes (2)"); c < 0 || c > 5 { + t.Errorf("the file list starts at column %d, want it at the left", c) + } + if ls := lines(s, "M a.go"); len(ls) == 0 || column(ls[0], "│") < 48 || column(ls[0], "│") > 52 { + t.Errorf("the file list rows %q, want a width of 50", ls) + } + tm.keys(keyOf["keys"]) + tm.waitUntil(func(s string) bool { + for _, re := range res { + if !re.MatchString(s) { + return false + } + } + return true + }, "show the keys of the config in the help") + }) + } +} diff --git a/test/limits_test.go b/test/limits_test.go new file mode 100644 index 0000000..428a0ee --- /dev/null +++ b/test/limits_test.go @@ -0,0 +1,144 @@ +package test + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/testgit" +) + +// fastImport runs git fast-import in repo with stream as its input. +func fastImport(t *testing.T, repo, stream string) { + t.Helper() + cmd := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "fast-import", "--quiet") + cmd.Dir = repo + cmd.Stdin = strings.NewReader(stream) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git fast-import: %v: %s", err, out) + } +} + +// TestTooManyChanges checks that the Changes tab names the number of +// changed files, without listing them, when there are too many. The +// files are committed but missing from the working tree, so the test +// writes no files. +func TestTooManyChanges(t *testing.T) { + repo := newRepo(t) + head := strings.TrimSpace(testgit.Git(t, repo, "rev-parse", "HEAD")) + var b strings.Builder + fmt.Fprintf(&b, "blob\nmark :1\ndata 2\nx\n\ncommit refs/heads/feat\n"+ + "committer a 0 +0000\ndata 4\nmany\nfrom %s\n", head) + for i := range 20_001 { + fmt.Fprintf(&b, "M 100644 :1 many/%d\n", i) + } + fastImport(t, repo, b.String()+"\n") + testgit.Git(t, repo, "read-tree", "HEAD") + tm := start(t, repo, opts{}) + // The two changes of the fixture make 20,003. + tm.waitFor("20003 files changed — too many to show", "Changes (20003)") +} + +// TestCommitPickerLimit checks that the commit picker lists at most 1,000 +// commits over the base, and says so in its title. +func TestCommitPickerLimit(t *testing.T) { + repo := newRepo(t) + head := strings.TrimSpace(testgit.Git(t, repo, "rev-parse", "HEAD")) + var b strings.Builder + for i := range 1000 { + fmt.Fprintf(&b, "commit refs/heads/feat\ncommitter a %d +0000\ndata 8\nc %05d\n", i, i) + if i == 0 { + fmt.Fprintf(&b, "from %s\n", head) + } + } + fastImport(t, repo, b.String()) + tm := start(t, repo, opts{}) + tm.keys("g") + tm.waitFor("commits · newest 1000 over main", "c 00999") +} + +// TestFindQueryLimit checks that the find bar stops taking characters +// once the query holds 256. +func TestFindQueryLimit(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("f", "ctrl+f") + tm.paste(strings.Repeat("x", 300)) + tm.waitFor("find xxxxxxxx", "no matches") + // If the query held more than 256 characters, some would remain. + tm.keys(slices.Repeat([]string{"bs"}, 256)...) + tm.typed("line 3") + tm.waitUntil(func(s string) bool { return sameLine(s, "find line 3", " 11") }, + "count 11 lines") +} + +// TestFindInLongLine checks that find shows a match far into a long line: +// by scrolling sideways, or when lines wrap and the line is taller than +// the screen, by scrolling down through it. +func TestFindInLongLine(t *testing.T) { + for _, wrap := range []string{"off", "on"} { + t.Run("wrap="+wrap, func(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "checkout", "--", "long.txt") + testgit.WriteFile(t, repo, "a.go", + "package a\n\n// start "+strings.Repeat("word ", 2000)+"NEEDLE end\nvar x = 1\n") + tm := start(t, repo, opts{height: 20, args: []string{"-wrap", wrap}}) + tm.waitFor("// start") + tm.waitGone("NEEDLE") + tm.keys("ctrl+f") + tm.typed("needle") + tm.keys("enter") + tm.waitFor("NEEDLE end") + }) + } +} + +// TestClickFindBarShortScreen checks that on a screen too short for the +// body, a click on the find bar does not count as a click on the code +// above it, which would move the cursor. +func TestClickFindBarShortScreen(t *testing.T) { + tm := start(t, newRepo(t), opts{height: 4}) + tm.keys("ctrl+f") + before := strings.Split(tm.waitFor("find "), "\n") + tm.click("find ", 1) + tm.typed("z") + after := strings.Split(tm.waitFor("find z"), "\n") + if before[2] != after[2] { + t.Errorf("the code line changed from %q to %q", before[2], after[2]) + } +} + +// cursorLine returns the line of s with the cursor ▶, or "". +func cursorLine(s string) string { + for l := range strings.Lines(s) { + if strings.Contains(l, "▶") { + return strings.TrimRight(l, "\n") + } + } + return "" +} + +// TestCursorShortScreen checks that on a screen with room for only one +// row of code, the cursor row stays on screen after each key that moves +// the cursor. +func TestCursorShortScreen(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + testgit.InitRepo(t, repo) + testgit.WriteFile(t, repo, "u.txt", numbered("line", 1, 40)) + testgit.Git(t, repo, "add", ".") + testgit.Git(t, repo, "commit", "-qm", "first") + tm := start(t, repo, opts{width: 40, height: 4}) + tm.keys("2") + tm.waitCursor("line 1") + prev := cursorLine(tm.screen()) + for _, k := range []string{"down", "down", "ctrl+d", "up", "pgdown", "j"} { + tm.keys(k) + s := tm.waitUntil(func(s string) bool { + l := cursorLine(s) + return l != "" && l != prev + }, "show the cursor row after "+k) + prev = cursorLine(s) + } +} diff --git a/test/log_test.go b/test/log_test.go new file mode 100644 index 0000000..357c8e2 --- /dev/null +++ b/test/log_test.go @@ -0,0 +1,52 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLogPrivate checks that -log creates a log file that only the user can +// read, and that the log names the keys that the user presses but not the +// text typed into the comment box or the find bar. +func TestLogPrivate(t *testing.T) { + log := filepath.Join(t.TempDir(), "log") + tm := start(t, newRepo(t), opts{args: []string{"-log", log}}) + tm.keys("]", "c") + tm.typed("hunter2") + tm.keys("enter") + tm.waitFor("💬 hunter2") + tm.keys("/") + tm.waitFor("no preview") + tm.typed("swordfish") + tm.waitFor("› swordfish", "no matches") + tm.keys("esc") + tm.waitGone("› swordfish") + tm.keys("q", "Q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(log) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("the log has mode %v, want -rw-------", perm) + } + b, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{"key=]", "key=c", "key=enter", "key=text"} { + if !strings.Contains(s, want) { + t.Errorf("the log has no %q. It holds:\n%s", want, s) + } + } + for _, secret := range []string{"key=h", "key=2", "key=s", "key=w"} { + if strings.Contains(s, secret+" ") { + t.Errorf("the log has %q, a key typed as text. It holds:\n%s", secret, s) + } + } +}