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() }