// SPDX-License-Identifier: AGPL-3.0-or-later package web import ( "context" "fmt" "strings" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" ) // dataOf is colours of n lines, about 8n+64 bytes. func dataOf(n int) *hlData { d := &hlData{lineEnd: make([]uint32, n), runs: make([]hlRun, n)} for i := range n { d.runs[i] = hlRun{end: 3, class: 1} d.lineEnd[i] = uint32(i + 1) } return d } func withID(f domain.FileDiff, n int) domain.FileDiff { f.OldID, f.NewID = fakeBlob(n, 0), fakeBlob(n, 1) return f } // A file whose colours are in the cache is coloured with no lexing: with // every lexing slot taken, a miss is plain and a hit is not. func TestHLCacheHit(t *testing.T) { fd := withID(hunkOf("a.go", " package a", "+var x = 1 // c"), 1) c := newHLCache(1 << 20) first := c.files(context.Background(), []domain.FileDiff{fd}) assert.Contains(t, rendered(t, first[0])[1], `class="kd"`) assert.Len(t, c.byKey, 1) for range cap(lexSlots) { lexSlots <- struct{}{} } defer func() { for range cap(lexSlots) { <-lexSlots } }() hit := c.files(context.Background(), []domain.FileDiff{fd}) assert.Equal(t, first, hit, "same page from the cache") other := c.files(context.Background(), []domain.FileDiff{withID(fd, 2)}) assert.NotContains(t, rendered(t, other[0])[1], `class=`, "another blob: a miss, and no slot to lex") assert.Len(t, c.byKey, 1, "a failed lexing is not kept") } // The cache holds colours, not text: the text of the page is the one of the // diff being rendered, escaped as ever. func TestHLCacheKeepsNoText(t *testing.T) { c := newHLCache(1 << 20) a := withID(hunkOf("a.go", "+x := \"one\""), 1) b := withID(hunkOf("a.go", "+x := \"two\""), 1) // same key, other text: cannot happen, but must not leak c.files(context.Background(), []domain.FileDiff{a}) got := c.files(context.Background(), []domain.FileDiff{b}) h := rendered(t, got[0])[0] assert.Contains(t, h, "<i>two</i>") assert.NotContains(t, h, "one") } // The budget is in bytes: the least recently used entries go first, a hit // makes an entry young, and an entry over a quarter of the budget is not kept. func TestHLCacheEvictsByBytes(t *testing.T) { t.Parallel() one := entrySize("k0", dataOf(100)) c := newHLCache(one*4 + one/2) // four entries fit, not five; one is under a quarter ctx := context.Background() add := func(k string) { c.load(ctx, k, func() *hlData { return dataOf(100) }) } add("k0") add("k1") add("k2") add("k3") assert.Len(t, c.byKey, 4) c.load(ctx, "k0", func() *hlData { t.Fatal("k0 is cached"); return nil }) // k0 is the youngest now add("k4") // pushes out k1, the oldest assert.Len(t, c.byKey, 4) assert.Contains(t, c.byKey, "k0") assert.NotContains(t, c.byKey, "k1") assert.LessOrEqual(t, c.size, c.budget) // the sum of the sizes of the entries is what is counted sum := 0 for _, el := range c.byKey { sum += el.Value.(*hlEntry).size } assert.Equal(t, sum, c.size) // over a quarter of the budget: served, not kept, nothing pushed out big := 0 c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) }) c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) }) assert.Equal(t, 2, big) assert.NotContains(t, c.byKey, "big") assert.Len(t, c.byKey, 4) // many entries: the size never goes over the budget for i := range 200 { add(fmt.Sprintf("n%d", i)) require.LessOrEqual(t, c.size, c.budget) } } // A nil cache and an empty key keep nothing; a build that gives nothing is // not kept and is tried again. func TestHLCacheNotKept(t *testing.T) { t.Parallel() ctx := context.Background() n := 0 build := func() *hlData { n++; return nil } var nilCache *hlCache nilCache.load(ctx, "k", build) newHLCache(1<<20).load(ctx, "", build) c := newHLCache(1 << 20) c.load(ctx, "k", build) c.load(ctx, "k", build) assert.Equal(t, 4, n) assert.Empty(t, c.byKey) assert.Empty(t, c.flight) } // N concurrent first requests for one key lex once: the others wait for the // first, and all get its data. Run with -race. func TestHLCacheDedup(t *testing.T) { t.Parallel() c := newHLCache(1 << 20) const n = 32 var builds atomic.Int32 release := make(chan struct{}) var wg sync.WaitGroup got := make([]*hlData, n) for i := range n { wg.Add(1) go func() { defer wg.Done() got[i] = c.load(context.Background(), "k", func() *hlData { builds.Add(1) <-release // hold the build until every other caller has joined return dataOf(10) }) }() } require.Eventually(t, func() bool { c.mu.Lock() defer c.mu.Unlock() f := c.flight["k"] return f != nil && f.waiters == n-1 }, 5*time.Second, time.Millisecond) close(release) wg.Wait() assert.EqualValues(t, 1, builds.Load()) for i := range n { require.NotNil(t, got[i]) assert.Same(t, got[0], got[i], "one result for all") } assert.Empty(t, c.flight) assert.Len(t, c.byKey, 1) } // The same through the diff: concurrent pages of one commit lex its file once. func TestHLCacheDedupFiles(t *testing.T) { c := newHLCache(1 << 20) var lines []string for range 300 { lines = append(lines, "+var v = 1 // 0123456789") } fd := withID(hunkOf("a.go", lines...), 7) const n = 16 var wg sync.WaitGroup pages := make([][]DiffFile, n) for i := range n { wg.Add(1) go func() { defer wg.Done() pages[i] = c.files(context.Background(), []domain.FileDiff{fd}) }() } wg.Wait() for i := range n { assert.Equal(t, pages[0], pages[i]) assert.Contains(t, rendered(t, pages[i][0])[0], `class="kd"`) } assert.Len(t, c.byKey, 1) } // A waiter that gives up is freed at once and shows the file plain; the // build goes on and its result is kept for the next request. func TestHLCacheWaiterCancelled(t *testing.T) { t.Parallel() c := newHLCache(1 << 20) release := make(chan struct{}) started := make(chan struct{}) done := make(chan struct{}) go func() { defer close(done) c.load(context.Background(), "k", func() *hlData { close(started); <-release; return dataOf(5) }) }() <-started ctx, cancel := context.WithCancel(context.Background()) cancel() assert.Nil(t, c.load(ctx, "k", func() *hlData { t.Error("joins the build"); return nil })) close(release) <-done assert.NotNil(t, c.load(context.Background(), "k", func() *hlData { t.Error("kept"); return nil })) } // A build that panics frees the callers that wait and is not kept. func TestHLCacheBuildPanics(t *testing.T) { t.Parallel() c := newHLCache(1 << 20) started := make(chan struct{}) release := make(chan struct{}) res := make(chan *hlData, 1) go func() { defer func() { _ = recover() }() c.load(context.Background(), "k", func() *hlData { close(started); <-release; panic("lexer bug") }) }() <-started go func() { res <- c.load(context.Background(), "k", func() *hlData { return dataOf(1) }) }() require.Eventually(t, func() bool { c.mu.Lock() defer c.mu.Unlock() return c.flight["k"] != nil && c.flight["k"].waiters == 1 }, 5*time.Second, time.Millisecond) close(release) select { case d := <-res: assert.Nil(t, d) case <-time.After(5 * time.Second): t.Fatal("the waiter is stuck") } assert.Empty(t, c.flight) assert.Empty(t, c.byKey) } // The key tells apart what changes the colours: the blobs, the language, the // shape of the hunks. func TestDiffKey(t *testing.T) { t.Parallel() f := withID(hunkOf("a.go", " a", "+b"), 1) k := diffKey("Go", f) assert.NotEmpty(t, k) assert.Equal(t, k, diffKey("Go", f)) assert.NotEqual(t, k, diffKey("C", f)) assert.NotEqual(t, k, diffKey("Go", withID(f, 2))) assert.NotEqual(t, k, diffKey("Go", withID(hunkOf("a.go", " a", "+b", "+c"), 1))) assert.Empty(t, diffKey("Go", hunkOf("a.go", " a")), "no blob ids: not kept") assert.False(t, strings.Contains(k, "a.go"), "the path is not in the key: two paths with the same blobs share") } // The lines /context loads are cached under the blob of the file. func TestHLCacheContext(t *testing.T) { c := newHLCache(1 << 20) lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1"} blob := fakeBlob(3, 1).String() ctx := context.Background() first := c.highlightContext(ctx, blob, "a.go", lines, 1, 4) require.Len(t, first, 3) assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, first[1]) assert.Len(t, c.byKey, 1) for range cap(lexSlots) { lexSlots <- struct{}{} } defer func() { for range cap(lexSlots) { <-lexSlots } }() assert.Equal(t, first, c.highlightContext(ctx, blob, "a.go", lines, 1, 4), "from the cache") assert.Equal(t, first[1:], c.highlightContext(ctx, blob, "a.go", lines, 2, 4), "other window, same colours") assert.Nil(t, c.highlightContext(ctx, fakeBlob(4, 1).String(), "a.go", lines, 1, 4), "other blob: no slot, plain") }