package plan
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// stubBackend writes a Python module that emits a canned event stream, so the
// reader is tested against the real protocol without running an update.
func stubBackend(t *testing.T, body string) Backend {
t.Helper()
dir := t.TempDir()
mod := filepath.Join(dir, "stubupdater")
if err := os.MkdirAll(mod, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(body), 0o644)
return Backend{Python: "python3", Module: "stubupdater",
Env: []string{"PYTHONPATH=" + dir}}
}
func drain(t *testing.T, s *Stream, timeout time.Duration) []Event {
t.Helper()
var events []Event
deadline := time.After(timeout)
for {
select {
case event, ok := <-s.Events:
if !ok {
return events
}
events = append(events, event)
case err, ok := <-s.ProtocolErrors:
if !ok {
// a closed channel is always ready and yields nil, so disabling
// this arm is the only way to stop selecting on it
s.ProtocolErrors = nil
continue
}
t.Errorf("protocol error: %v", err)
case <-deadline:
t.Fatal("timed out waiting for the stream to close")
}
}
}
func TestStartApplyReadsTheProtocol(t *testing.T) {
backend := stubBackend(t, `
import json, sys
def emit(**kw):
sys.stdout.write(json.dumps(kw) + "\n")
sys.stdout.flush()
emit(event="start", phases=["repo","aur"], total=3)
emit(event="phase_start", phase="aur", command="paru -S --needed", total=3)
emit(event="build", packages=[{"name":"a","phase":"cargo","elapsed":1.5,"jobs":2}],
download_bytes=2048, download_rate=512.0, done=1)
emit(event="finished", name="a", elapsed=2.0, state="done", version="1-1", shared=True)
emit(event="phase_end", phase="aur", returncode=0, elapsed=3.0)
emit(event="done", returncode=0, elapsed=3.0, failed=[])
`)
stream, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
events := drain(t, stream, 20*time.Second)
if len(events) != 6 {
t.Fatalf("got %d events, want 6: %+v", len(events), events)
}
if events[0].Event != "start" || len(events[0].Phases) != 2 {
t.Errorf("start event = %+v", events[0])
}
if events[1].Command != "paru -S --needed" {
t.Errorf("phase_start = %+v", events[1])
}
if len(events[2].Packages) != 1 || events[2].Packages[0].Name != "a" {
t.Errorf("build event = %+v", events[2])
}
if events[2].Packages[0].Elapsed != 1.5 || events[2].DownloadBytes != 2048 {
t.Errorf("build numbers lost: %+v", events[2])
}
if !events[3].Shared || events[3].Version != "1-1" {
t.Errorf("finished event = %+v", events[3])
}
if events[5].Returncode != 0 {
t.Errorf("done event = %+v", events[5])
}
if err := stream.Wait(); err != nil {
t.Errorf("Wait: %v", err)
}
}
func TestStrayOutputOnStdoutIsReportedNotIgnored(t *testing.T) {
// the backend promises JSON only; a break must be loud, not silently skipped
backend := stubBackend(t, `
import json, sys
sys.stdout.write("this is not json\n")
sys.stdout.write(json.dumps({"event":"done","returncode":0}) + "\n")
sys.stdout.flush()
`)
stream, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
var protocolErrs []error
var events []Event
deadline := time.After(20 * time.Second)
for {
select {
case event, ok := <-stream.Events:
if !ok {
goto finished
}
events = append(events, event)
case err, ok := <-stream.ProtocolErrors:
if !ok {
stream.ProtocolErrors = nil
continue
}
protocolErrs = append(protocolErrs, err)
case <-deadline:
t.Fatal("timed out")
}
}
finished:
if len(protocolErrs) == 0 {
t.Error("a non-JSON line was silently accepted")
}
if len(events) != 1 || events[0].Event != "done" {
t.Errorf("valid events were lost: %+v", events)
}
}
func TestAFailingBackendReportsItsStatus(t *testing.T) {
backend := stubBackend(t, `
import json, sys
sys.stdout.write(json.dumps({"event":"done","returncode":4}) + "\n")
sys.stdout.flush()
sys.stderr.write("something went wrong\n")
sys.exit(4)
`)
stream, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
drain(t, stream, 20*time.Second)
if err := stream.Wait(); err == nil {
t.Error("a non-zero exit should be reported")
}
if !strings.Contains(stream.Stderr(), "something went wrong") {
t.Errorf("stderr was not captured: %q", stream.Stderr())
}
}
func TestSkipAndHoldBecomeArguments(t *testing.T) {
dir := t.TempDir()
mod := filepath.Join(dir, "stubupdater")
os.MkdirAll(mod, 0o755)
os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(`
import json, sys
sys.stdout.write(json.dumps({"event":"start","phases":[],"total":0}) + "\n")
sys.stdout.flush()
`), 0o644)
// echo argv into the event so the test can assert on it
os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(`
import json, sys
sys.stdout.write(json.dumps({"event":"start","phases":[],"total":len(sys.argv),"command":" ".join(sys.argv[1:])}) + "\n")
sys.stdout.flush()
`), 0o644)
backend := Backend{Python: "python3", Module: "stubupdater",
Env: []string{"PYTHONPATH=" + dir}}
stream, err := backend.StartApply(context.Background(),
[]string{"skipme"}, []string{"holdme"})
if err != nil {
t.Fatalf("StartApply: %v", err)
}
events := drain(t, stream, 20*time.Second)
if len(events) != 1 {
t.Fatalf("events = %+v", events)
}
argv := events[0].Command
for _, want := range []string{"apply", "--json-stream", "--skip skipme", "--hold holdme"} {
if !strings.Contains(argv, want) {
t.Errorf("argv is missing %q: %s", want, argv)
}
}
}
// --------------------------------------------------------------------------
// version skew between the two halves
// --------------------------------------------------------------------------
// stubPlan serves a canned plan document, so the Go side can be tested against
// payloads it was not built for.
func stubPlan(t *testing.T, body string) Backend {
t.Helper()
dir := t.TempDir()
mod := filepath.Join(dir, "stubupdater")
if err := os.MkdirAll(mod, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(mod, "__init__.py"), []byte(""), 0o644)
os.WriteFile(filepath.Join(mod, "__main__.py"), []byte(body), 0o644)
return Backend{Python: "python3", Module: "stubupdater",
Env: []string{"PYTHONPATH=" + dir}}
}
func TestAPlanMissingSectionsIsNotAnError(t *testing.T) {
// the two halves are meant to be updatable independently, so an older or
// newer backend must not break the front end
backend := stubPlan(t, `
import json, sys
sys.stdout.write(json.dumps({"repo": []}) + "\n")
`)
p, err := backend.Load(context.Background())
if err != nil {
t.Fatalf("a plan with only one field was rejected: %v", err)
}
if len(p.Aur) != 0 || len(p.Vcs) != 0 || len(p.Advisories) != 0 {
t.Errorf("absent fields should be zero, got %+v", p)
}
}
func TestAnEmptyPlanIsNotAnError(t *testing.T) {
backend := stubPlan(t, `
import json, sys
sys.stdout.write("{}\n")
`)
p, err := backend.Load(context.Background())
if err != nil {
t.Fatalf("an empty object was rejected: %v", err)
}
if p == nil {
t.Fatal("nil plan")
}
}
func TestUnknownFieldsAreIgnored(t *testing.T) {
// a newer backend adding fields must not break an older front end
backend := stubPlan(t, `
import json, sys
sys.stdout.write(json.dumps({"repo": [], "something_new": {"a": 1},
"aur": [{"name": "x", "old": "1", "new": "2",
"tag": "U", "future_field": True}]}) + "\n")
`)
p, err := backend.Load(context.Background())
if err != nil {
t.Fatalf("an unknown field broke the load: %v", err)
}
if len(p.Aur) != 1 || p.Aur[0].Name != "x" {
t.Errorf("known fields were lost: %+v", p.Aur)
}
}
func TestMalformedJsonIsRejectedWithAClearError(t *testing.T) {
backend := stubPlan(t, `
import sys
sys.stdout.write("this is not json\n")
`)
if _, err := backend.Load(context.Background()); err == nil {
t.Error("malformed JSON was accepted")
}
}
func TestStrayOutputBeforeTheJsonIsRejectedNotSilentlyUsed(t *testing.T) {
// the backend promises JSON on stdout; anything else is a protocol break
backend := stubPlan(t, `
import json, sys
sys.stdout.write("warning: something\n")
sys.stdout.write(json.dumps({"repo": []}) + "\n")
`)
if _, err := backend.Load(context.Background()); err == nil {
t.Error("output preceding the JSON was silently tolerated")
}
}
func TestANonZeroExitSurfacesTheBackendsOwnMessage(t *testing.T) {
backend := stubPlan(t, `
import sys
sys.stderr.write("could not read the package database\n")
sys.exit(3)
`)
_, err := backend.Load(context.Background())
if err == nil {
t.Fatal("a failing backend reported success")
}
if !strings.Contains(err.Error(), "package database") {
t.Errorf("the backend's message was lost: %v", err)
}
}
func TestATypeMismatchIsRejectedRatherThanGuessed(t *testing.T) {
// if the backend changes a field's type, failing loudly beats rendering
// whatever the zero value happens to be
backend := stubPlan(t, `
import json, sys
sys.stdout.write(json.dumps({"repo": [{"name": "x", "delta": "not a number"}]}) + "\n")
`)
if _, err := backend.Load(context.Background()); err == nil {
t.Error("a type mismatch was silently accepted")
}
}
// --------------------------------------------------------------------------
// the reader must not leak or wedge
// --------------------------------------------------------------------------
func TestStartingAStreamLeavesNoGoroutinesBehind(t *testing.T) {
before := runtime.NumGoroutine()
for i := 0; i < 8; i++ {
backend := stubBackend(t, `
import json, sys
for i in range(20):
sys.stdout.write(json.dumps({"event":"build","done":i}) + "\n")
sys.stdout.write(json.dumps({"event":"done","returncode":0}) + "\n")
sys.stdout.flush()
`)
s, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
drain(t, s, 20*time.Second)
if err := s.Wait(); err != nil {
t.Fatalf("Wait: %v", err)
}
}
// give the runtime a moment to reap
deadline := time.Now().Add(5 * time.Second)
for runtime.NumGoroutine() > before+2 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if after := runtime.NumGoroutine(); after > before+2 {
t.Errorf("goroutines grew from %d to %d across 8 streams", before, after)
}
}
func TestTheReaderDoesNotWedgeWhenTheConsumerStopsEarly(t *testing.T) {
// a consumer that stops reading must not deadlock the child on a full pipe,
// and must not hang the test forever either
backend := stubBackend(t, `
import json, sys
for i in range(5000):
sys.stdout.write(json.dumps({"event":"build","done":i}) + "\n")
sys.stdout.flush()
`)
s, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
// read one event then abandon the stream
select {
case <-s.Events:
case <-time.After(20 * time.Second):
t.Fatal("no first event")
}
done := make(chan struct{})
go func() {
// drain in the background so the child can finish; this proves the
// reader keeps working independently of the caller's attention
for range s.Events {
}
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("the reader wedged when the consumer paused")
}
_ = s.Wait()
}
func TestProtocolErrorsAreDrainedSoTheReaderCannotBlock(t *testing.T) {
// the error channel is buffered; a flood of bad lines must not stall the
// reader forever with nobody listening
backend := stubBackend(t, `
import sys
for i in range(500):
sys.stdout.write("not json\n")
sys.stdout.write('{"event":"done","returncode":0}\n')
sys.stdout.flush()
`)
s, err := backend.StartApply(context.Background(), nil, nil)
if err != nil {
t.Fatalf("StartApply: %v", err)
}
// deliberately read only the events channel
var events int
timeout := time.After(30 * time.Second)
for {
select {
case _, ok := <-s.Events:
if !ok {
goto finished
}
events++
case <-timeout:
t.Fatal("the reader blocked on a full error channel")
}
}
finished:
if events == 0 {
t.Error("the valid event was lost among the bad lines")
}
_ = s.Wait()
}