457 lines
12 KiB
Go
457 lines
12 KiB
Go
package lsp
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/microsoft/typescript-go/internal/diagnostics"
|
|
"github.com/microsoft/typescript-go/internal/locale"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
)
|
|
|
|
type progressCall struct {
|
|
method string // "create", "begin", "report", "end"
|
|
token string
|
|
title string // begin only
|
|
msg string // begin/report only
|
|
}
|
|
|
|
type fakeProgressReporter struct {
|
|
mu sync.Mutex
|
|
calls []progressCall
|
|
ctx context.Context
|
|
}
|
|
|
|
func (f *fakeProgressReporter) done() <-chan struct{} {
|
|
return f.ctx.Done()
|
|
}
|
|
|
|
func (f *fakeProgressReporter) localize(msg *diagnostics.Message, args ...any) string {
|
|
return msg.Localize(locale.Default, args...)
|
|
}
|
|
|
|
func (f *fakeProgressReporter) createWorkDoneProgress(token string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, progressCall{method: "create", token: token})
|
|
}
|
|
|
|
func (f *fakeProgressReporter) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
switch {
|
|
case value.Begin != nil:
|
|
msg := ""
|
|
if value.Begin.Message != nil {
|
|
msg = *value.Begin.Message
|
|
}
|
|
f.calls = append(f.calls, progressCall{method: "begin", token: token, title: value.Begin.Title, msg: msg})
|
|
case value.Report != nil:
|
|
msg := ""
|
|
if value.Report.Message != nil {
|
|
msg = *value.Report.Message
|
|
}
|
|
f.calls = append(f.calls, progressCall{method: "report", token: token, msg: msg})
|
|
case value.End != nil:
|
|
f.calls = append(f.calls, progressCall{method: "end", token: token})
|
|
}
|
|
}
|
|
|
|
func (f *fakeProgressReporter) getCalls() []progressCall {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]progressCall(nil), f.calls...)
|
|
}
|
|
|
|
func TestProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("StartFinishBeforeDelay", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
|
|
|
p.start(diagnostics.Project_0, "myProject")
|
|
synctest.Wait()
|
|
|
|
// Finish before the delay fires — no UI should appear.
|
|
p.finish(diagnostics.Project_0, "myProject")
|
|
synctest.Wait()
|
|
|
|
// Advance time past the delay to ensure no progress is sent.
|
|
time.Sleep(600 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
if len(calls) != 0 {
|
|
t.Fatalf("expected no progress calls for fast operation, got %v", calls)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("ShowsAfterDelay", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
|
|
|
p.start(diagnostics.Project_0, "myProject")
|
|
synctest.Wait()
|
|
|
|
// Let the delay fire.
|
|
time.Sleep(500 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0].method != "create" {
|
|
t.Fatalf("expected create, got %v", calls[0])
|
|
}
|
|
if calls[1].method != "begin" {
|
|
t.Fatalf("expected begin, got %v", calls[1])
|
|
}
|
|
if calls[1].title != diagnostics.Loading.String() {
|
|
t.Fatalf("expected title %q, got %q", diagnostics.Loading.String(), calls[1].title)
|
|
}
|
|
|
|
// Finish the operation.
|
|
p.finish(diagnostics.Project_0, "myProject")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
last := calls[len(calls)-1]
|
|
if last.method != "end" {
|
|
t.Fatalf("expected end, got %v", last)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("ReportsMultipleOperations", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
|
|
|
// Start two different operations.
|
|
p.start(diagnostics.Project_0, "projA")
|
|
p.start(diagnostics.Project_0, "projB")
|
|
synctest.Wait()
|
|
|
|
// Let the delay fire.
|
|
time.Sleep(100 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
// Should have: create, begin (with first message).
|
|
if len(calls) < 2 {
|
|
t.Fatalf("expected at least 2 calls, got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0].method != "create" {
|
|
t.Fatalf("expected create, got %v", calls[0])
|
|
}
|
|
if calls[1].method != "begin" {
|
|
t.Fatalf("expected begin, got %v", calls[1])
|
|
}
|
|
|
|
// Finish one — should send a report with the remaining operation.
|
|
p.finish(diagnostics.Project_0, "projA")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
found := false
|
|
for _, c := range calls {
|
|
if c.method == "report" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected a report after partial finish, got %v", calls)
|
|
}
|
|
|
|
// Finish the second — should send end.
|
|
p.finish(diagnostics.Project_0, "projB")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
last := calls[len(calls)-1]
|
|
if last.method != "end" {
|
|
t.Fatalf("expected end, got %v", last)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("RefCounting", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
|
|
|
// Start the same operation twice (ref count = 2).
|
|
p.start(diagnostics.Project_0, "proj")
|
|
p.start(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
// Finish once (ref count = 1) — should NOT end.
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
for _, c := range calls {
|
|
if c.method == "end" {
|
|
t.Fatalf("unexpected end with ref count > 0: %v", calls)
|
|
}
|
|
}
|
|
|
|
// Finish again (ref count = 0) — should end.
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
last := calls[len(calls)-1]
|
|
if last.method != "end" {
|
|
t.Fatalf("expected end when ref count reaches 0, got %v", last)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("NewTokenAfterEnd", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
|
|
|
// First cycle.
|
|
p.start(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
time.Sleep(100 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
firstToken := calls[0].token
|
|
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
// Second cycle — should get a new token.
|
|
p.start(diagnostics.Project_0, "proj2")
|
|
synctest.Wait()
|
|
time.Sleep(100 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
var secondToken string
|
|
for _, c := range calls {
|
|
if c.method == "create" && c.token != firstToken {
|
|
secondToken = c.token
|
|
break
|
|
}
|
|
}
|
|
if secondToken == "" {
|
|
t.Fatalf("expected a new token for second cycle, got calls: %v", calls)
|
|
}
|
|
if firstToken == secondToken {
|
|
t.Fatalf("expected different tokens, both were %q", firstToken)
|
|
}
|
|
|
|
p.finish(diagnostics.Project_0, "proj2")
|
|
synctest.Wait()
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("StartBeforeDelayThenMoreAfterDelay", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 200*time.Millisecond)
|
|
|
|
// Start before delay.
|
|
p.start(diagnostics.Project_0, "projA")
|
|
synctest.Wait()
|
|
|
|
// Let delay fire.
|
|
time.Sleep(200 * time.Millisecond)
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
if len(calls) < 2 {
|
|
t.Fatalf("expected create + begin after delay, got %v", calls)
|
|
}
|
|
|
|
// Start another operation after delay — should send a report immediately.
|
|
p.start(diagnostics.Project_0, "projB")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
last := calls[len(calls)-1]
|
|
if last.method != "report" {
|
|
t.Fatalf("expected report for new start after delay, got %v", last)
|
|
}
|
|
|
|
// Clean up.
|
|
p.finish(diagnostics.Project_0, "projA")
|
|
p.finish(diagnostics.Project_0, "projB")
|
|
synctest.Wait()
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("FinishWithNoActiveToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
|
|
|
// Finish without any prior start — should be a no-op.
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
if len(calls) != 0 {
|
|
t.Fatalf("expected no calls for orphan finish, got %v", calls)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("ShutdownDuringStartAndFinish", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond)
|
|
|
|
// Cancel context so the run goroutine exits.
|
|
cancel()
|
|
synctest.Wait()
|
|
|
|
// Fill the channel buffer so start/finish block on send.
|
|
for range cap(p.ch) {
|
|
p.ch <- progressEvent{message: diagnostics.Project_0, args: []any{"fill"}}
|
|
}
|
|
|
|
// These should return immediately via the done() path
|
|
// since the channel is full and the context is cancelled.
|
|
p.start(diagnostics.Project_0, "proj")
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
})
|
|
})
|
|
|
|
t.Run("ShutdownWithActiveTimer", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
|
|
|
// Start an operation so the delay timer is created.
|
|
p.start(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
// Shutdown while the delay timer is still pending.
|
|
cancel()
|
|
synctest.Wait()
|
|
})
|
|
})
|
|
|
|
t.Run("ZeroDelay", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 0)
|
|
|
|
// With zero delay, progress should begin immediately.
|
|
p.start(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0].method != "create" {
|
|
t.Fatalf("expected create, got %v", calls[0])
|
|
}
|
|
if calls[1].method != "begin" {
|
|
t.Fatalf("expected begin, got %v", calls[1])
|
|
}
|
|
if calls[1].msg != "Project 'proj'" {
|
|
t.Fatalf("expected message %q, got %q", "Project 'proj'", calls[1].msg)
|
|
}
|
|
|
|
// Start+finish should still produce begin and end.
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls = reporter.getCalls()
|
|
last := calls[len(calls)-1]
|
|
if last.method != "end" {
|
|
t.Fatalf("expected end, got %v", last)
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
|
|
t.Run("FinishBeforeDelayNoBegun", func(t *testing.T) {
|
|
t.Parallel()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
reporter := &fakeProgressReporter{ctx: ctx}
|
|
p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond)
|
|
|
|
// Start, then finish before delay — begun is false, so no end is sent.
|
|
p.start(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
p.finish(diagnostics.Project_0, "proj")
|
|
synctest.Wait()
|
|
|
|
calls := reporter.getCalls()
|
|
for _, c := range calls {
|
|
if c.method == "end" {
|
|
t.Fatalf("unexpected end when begun=false: %v", calls)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
})
|
|
})
|
|
}
|