324 lines
9.1 KiB
Go
324 lines
9.1 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"net/http/httptest"
|
|
|
|
"speedtest/internal/probe"
|
|
recordstore "speedtest/internal/store"
|
|
appweb "speedtest/internal/web"
|
|
)
|
|
|
|
func TestRunCompletesBrowserCompatibleSpeedTest(t *testing.T) {
|
|
store := &memoryStore{}
|
|
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
summary, err := Run(context.Background(), Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: 20 * time.Millisecond,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if summary.Target == "" || summary.AvgDownloadMbps <= 0 || summary.AvgUploadMbps <= 0 {
|
|
t.Fatalf("summary = %#v, want target and upload/download speeds", summary)
|
|
}
|
|
|
|
records := store.snapshot()
|
|
if len(records) != 1 {
|
|
t.Fatalf("records = %d, want 1", len(records))
|
|
}
|
|
if records[0].Summary.ID != summary.ID {
|
|
t.Fatalf("record summary id = %q, want %q", records[0].Summary.ID, summary.ID)
|
|
}
|
|
if !hasKind(records[0].Samples, "download") || !hasKind(records[0].Samples, "upload") {
|
|
t.Fatalf("samples = %#v, want download and upload samples", records[0].Samples)
|
|
}
|
|
}
|
|
|
|
func TestRunWaitsInQueueUntilBusySessionCompletes(t *testing.T) {
|
|
store := &memoryStore{}
|
|
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
busy := createBusySession(t, httpServer.URL)
|
|
go func() {
|
|
time.Sleep(30 * time.Millisecond)
|
|
completeBusySession(t, httpServer.URL, busy)
|
|
}()
|
|
|
|
var queueMessages int
|
|
summary, err := Run(context.Background(), Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: 20 * time.Millisecond,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
QueueInterval: 10 * time.Millisecond,
|
|
OnQueueWaiting: func(message string) { queueMessages++ },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
if summary.ID == "" || summary.AvgDownloadMbps <= 0 || summary.AvgUploadMbps <= 0 {
|
|
t.Fatalf("summary = %#v, want completed queued test", summary)
|
|
}
|
|
if queueMessages == 0 {
|
|
t.Fatal("OnQueueWaiting was not called while queued")
|
|
}
|
|
}
|
|
|
|
func TestRunCancelsQueuedSessionWhenContextStops(t *testing.T) {
|
|
store := &memoryStore{}
|
|
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
busy := createBusySession(t, httpServer.URL)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
_, err := Run(ctx, Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: 20 * time.Millisecond,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
QueueInterval: time.Hour,
|
|
OnQueueWaiting: func(message string) {
|
|
cancel()
|
|
},
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run error = %v, want context canceled", err)
|
|
}
|
|
|
|
completeBusySession(t, httpServer.URL, busy)
|
|
|
|
serverURL, err := url.Parse(httpServer.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse server URL: %v", err)
|
|
}
|
|
session, err := createSession(context.Background(), &http.Client{Timeout: time.Second}, serverURL, Options{
|
|
ServerURL: httpServer.URL,
|
|
Target: serverURL.Host,
|
|
Duration: time.Second,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
QueueInterval: time.Millisecond,
|
|
OnQueueWaiting: nil,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create next session: %v", err)
|
|
}
|
|
if session.Status != "ready" || session.QueuePosition != 0 {
|
|
t.Fatalf("next session = %#v, want ready after canceled queued session was removed", session)
|
|
}
|
|
}
|
|
|
|
func TestRunPersistsPartialRecordWhenCanceledAfterSamples(t *testing.T) {
|
|
store := &memoryStore{}
|
|
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
canceled := false
|
|
_, err := Run(ctx, Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: time.Second,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
OnSample: func(sample probe.Sample) {
|
|
if sample.Kind == "download" && !canceled {
|
|
canceled = true
|
|
cancel()
|
|
}
|
|
},
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run error = %v, want context canceled", err)
|
|
}
|
|
|
|
records := store.snapshot()
|
|
if len(records) != 1 {
|
|
t.Fatalf("records = %d, want one partial record", len(records))
|
|
}
|
|
if !hasKind(records[0].Samples, "download") {
|
|
t.Fatalf("samples = %#v, want partial download sample persisted", records[0].Samples)
|
|
}
|
|
if records[0].Summary.Samples != len(records[0].Samples) {
|
|
t.Fatalf("summary samples = %d, want %d", records[0].Summary.Samples, len(records[0].Samples))
|
|
}
|
|
}
|
|
|
|
func TestRunIperf3CompletesOneOffServerSession(t *testing.T) {
|
|
store := &memoryStore{}
|
|
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
summary, err := RunIperf3(context.Background(), Iperf3Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: time.Second,
|
|
Protocol: "tcp",
|
|
Reverse: true,
|
|
Iperf3Path: fakeIperf3(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunIperf3 returned error: %v", err)
|
|
}
|
|
if summary.AvgDownloadMbps != 87 || !strings.Contains(summary.Target, "iperf3/tcp") {
|
|
t.Fatalf("summary = %#v, want parsed iperf3 download result", summary)
|
|
}
|
|
records := store.snapshot()
|
|
if len(records) != 1 || records[0].Summary.ID != summary.ID {
|
|
t.Fatalf("records = %#v, want stored iperf3 summary", records)
|
|
}
|
|
}
|
|
|
|
func TestRunIperf3FailedCommandReleasesOneOffServerSession(t *testing.T) {
|
|
server := appweb.New(appweb.Options{Store: &memoryStore{}, Runner: noopRunner, Iperf3Path: fakeIperf3(t)})
|
|
httpServer := httptest.NewServer(server.Handler())
|
|
defer httpServer.Close()
|
|
|
|
_, err := RunIperf3(context.Background(), Iperf3Options{
|
|
ServerURL: httpServer.URL,
|
|
Duration: time.Second,
|
|
Protocol: "tcp",
|
|
Iperf3Path: fakeFailingIperf3Client(t),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "iperf3 failed") {
|
|
t.Fatalf("RunIperf3 error = %v, want iperf3 failure", err)
|
|
}
|
|
|
|
createBusySession(t, httpServer.URL)
|
|
}
|
|
|
|
func createBusySession(t *testing.T, serverURL string) createResponse {
|
|
t.Helper()
|
|
server, err := url.Parse(serverURL)
|
|
if err != nil {
|
|
t.Fatalf("parse server URL: %v", err)
|
|
}
|
|
httpClient := http.Client{Timeout: time.Second}
|
|
session, err := createSession(context.Background(), &httpClient, server, Options{
|
|
Target: server.Host,
|
|
Duration: time.Second,
|
|
Timeout: time.Second,
|
|
DownloadBytes: 1024,
|
|
UploadBytes: 512,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create busy session: %v", err)
|
|
}
|
|
return session
|
|
}
|
|
|
|
func completeBusySession(t *testing.T, serverURL string, session createResponse) {
|
|
t.Helper()
|
|
server, err := url.Parse(serverURL)
|
|
if err != nil {
|
|
t.Fatalf("parse server URL: %v", err)
|
|
}
|
|
httpClient := http.Client{Timeout: time.Second}
|
|
now := time.Now().UTC()
|
|
if _, err := completeSession(context.Background(), &httpClient, server, session.CompleteURL, now, now, nil); err != nil {
|
|
t.Errorf("complete busy session: %v", err)
|
|
}
|
|
}
|
|
|
|
func hasKind(samples []probe.Sample, kind string) bool {
|
|
for _, sample := range samples {
|
|
if sample.Kind == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type memoryStore struct {
|
|
mu sync.Mutex
|
|
records []recordstore.Record
|
|
}
|
|
|
|
func (s *memoryStore) Append(ctx context.Context, record recordstore.Record) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.records = append([]recordstore.Record{record}, s.records...)
|
|
return nil
|
|
}
|
|
|
|
func (s *memoryStore) List(ctx context.Context, limit int) ([]recordstore.Record, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if limit < 1 || limit > len(s.records) {
|
|
limit = len(s.records)
|
|
}
|
|
out := make([]recordstore.Record, limit)
|
|
copy(out, s.records[:limit])
|
|
return out, nil
|
|
}
|
|
|
|
func (s *memoryStore) snapshot() []recordstore.Record {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]recordstore.Record, len(s.records))
|
|
copy(out, s.records)
|
|
return out
|
|
}
|
|
|
|
func noopRunner(ctx context.Context, request appweb.TestRequest, emit func(appweb.Event) error) (probe.Summary, error) {
|
|
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, nil
|
|
}
|
|
|
|
func fakeIperf3(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "iperf3")
|
|
script := `#!/bin/sh
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "-s" ]; then
|
|
sleep 10
|
|
exit 0
|
|
fi
|
|
done
|
|
cat <<'JSON'
|
|
{"end":{"sum_sent":{"bytes":1048576,"bits_per_second":88000000},"sum_received":{"bytes":1048576,"bits_per_second":87000000}}}
|
|
JSON
|
|
`
|
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
t.Fatalf("write fake iperf3: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func fakeFailingIperf3Client(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "iperf3")
|
|
script := `#!/bin/sh
|
|
echo "simulated iperf3 failure" >&2
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
t.Fatalf("write fake failing iperf3: %v", err)
|
|
}
|
|
return path
|
|
}
|