diff --git a/core/application/addon_admin_auth_test.go b/core/application/addon_admin_auth_test.go index 6c1d9e06c..72dedb457 100644 --- a/core/application/addon_admin_auth_test.go +++ b/core/application/addon_admin_auth_test.go @@ -10,13 +10,14 @@ import ( "net/http" "net/http/httptest" "path/filepath" - "testing" "time" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/services/addons/hostops" "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type recordingExporter struct { @@ -34,62 +35,51 @@ func (r *recordingExporter) ExportModel(context.Context, string, string, schema. // With an account database the role decides in every mode, so the local-owner // mode does not make a non-administrator an administrator. -func TestTheAdminCheckWithAnAccountDatabaseFollowsTheRoleInEveryMode(t *testing.T) { - db, err := auth.InitDB(filepath.Join(t.TempDir(), "auth.db")) - if err != nil { - t.Fatal(err) - } - mk := func(id, role string) { - if err := db.Create(&auth.User{ID: id, Email: id + "@example.com", Role: role, Status: auth.StatusActive}).Error; err != nil { - t.Fatal(err) +var _ = Describe("the admin check with an account database", func() { + It("follows the role in every mode", func() { + db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db")) + Expect(err).ToNot(HaveOccurred()) + mk := func(id, role string) { + Expect(db.Create(&auth.User{ID: id, Email: id + "@example.com", Role: role, Status: auth.StatusActive}).Error).To(Succeed()) } - } - mk("admin", auth.RoleAdmin) - mk("granted", auth.RoleUser) - if err := auth.UpdateUserPermissions(db, "granted", auth.PermissionMap{"fine_tuning": true}); err != nil { - t.Fatal(err) - } + mk("admin", auth.RoleAdmin) + mk("granted", auth.RoleUser) + Expect(auth.UpdateUserPermissions(db, "granted", auth.PermissionMap{"fine_tuning": true})).To(Succeed()) - check := addonAdminCheck(db) - if check(contract.Actor{UserID: "granted", Mode: "local-owner"}) { - t.Fatal("a non-administrator is an administrator in local-owner mode with an account database") - } - if !check(contract.Actor{UserID: "admin", Mode: "local-owner"}) { - t.Fatal("an administrator is refused in local-owner mode") - } + check := addonAdminCheck(db) + Expect(check(contract.Actor{UserID: "granted", Mode: "local-owner"})).To(BeFalse(), + "a non-administrator is an administrator in local-owner mode with an account database") + Expect(check(contract.Actor{UserID: "admin", Mode: "local-owner"})).To(BeTrue(), + "an administrator is refused in local-owner mode") - in := hostops.NewInFlight() - b := hostops.New(in, addonFeatureCheck(db), contract.DefaultLimits()) - b.SetAdminCheck(check) - ex := &recordingExporter{} - if err := hostops.RegisterEngineModels(b, ex, nil); err != nil { - t.Fatal(err) - } + in := hostops.NewInFlight() + b := hostops.New(in, addonFeatureCheck(db), contract.DefaultLimits()) + b.SetAdminCheck(check) + ex := &recordingExporter{} + Expect(hostops.RegisterEngineModels(b, ex, nil)).To(Succeed()) - serve := func(user string) (int, contract.HostResponse) { - end := in.Begin(contract.Envelope{ - Request: contract.Request{AddonID: "fine-tuning", OperationID: "op1", Generation: 1, Action: "start", Arguments: json.RawMessage(`{}`)}, - Authority: contract.Authority{Actor: contract.Actor{UserID: user, CredentialID: "c1", Mode: "local-owner"}, Scope: "apply", ExpiresAt: time.Now().Add(time.Minute)}, - }) - defer end() - body, _ := json.Marshal(contract.HostRequest{OperationID: "op1", Op: "finetune.export", Arguments: json.RawMessage(`{"jobId":"j1"}`)}) - req := httptest.NewRequest(http.MethodPost, "/host", bytes.NewReader(body)) - req.Header.Set(contract.HostCredentialHeader, "secret") - rec := httptest.NewRecorder() - b.Handler("fine-tuning", "secret", []string{"finetune."}).ServeHTTP(rec, req) - var out contract.HostResponse - _ = json.Unmarshal(rec.Body.Bytes(), &out) - return rec.Code, out - } + serve := func(user string) (int, contract.HostResponse) { + end := in.Begin(contract.Envelope{ + Request: contract.Request{AddonID: "fine-tuning", OperationID: "op1", Generation: 1, Action: "start", Arguments: json.RawMessage(`{}`)}, + Authority: contract.Authority{Actor: contract.Actor{UserID: user, CredentialID: "c1", Mode: "local-owner"}, Scope: "apply", ExpiresAt: time.Now().Add(time.Minute)}, + }) + defer end() + body, _ := json.Marshal(contract.HostRequest{OperationID: "op1", Op: "finetune.export", Arguments: json.RawMessage(`{"jobId":"j1"}`)}) + req := httptest.NewRequest(http.MethodPost, "/host", bytes.NewReader(body)) + req.Header.Set(contract.HostCredentialHeader, "secret") + rec := httptest.NewRecorder() + b.Handler("fine-tuning", "secret", []string{"finetune."}).ServeHTTP(rec, req) + var out contract.HostResponse + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out + } - code, out := serve("granted") - if code != http.StatusForbidden || out.Error == nil || out.Error.Code != contract.HostFeature { - t.Fatalf("feature-holding non-admin: %d %+v", code, out.Error) - } - if ex.calls != 0 { - t.Fatalf("service called %d times for a non-administrator", ex.calls) - } - if code, out := serve("admin"); code != http.StatusOK { - t.Fatalf("administrator: %d %+v", code, out.Error) - } -} + code, out := serve("granted") + Expect(code).To(Equal(http.StatusForbidden)) + Expect(out.Error).ToNot(BeNil()) + Expect(out.Error.Code).To(Equal(contract.HostFeature)) + Expect(ex.calls).To(BeZero(), "service called for a non-administrator") + code, out = serve("admin") + Expect(code).To(Equal(http.StatusOK), "administrator: %+v", out.Error) + }) +}) diff --git a/core/application/addon_admin_test.go b/core/application/addon_admin_test.go index 2439ddf4f..31876313f 100644 --- a/core/application/addon_admin_test.go +++ b/core/application/addon_admin_test.go @@ -2,19 +2,19 @@ package application import ( - "testing" - "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // Without an account database the local owner is the administrator, and no // other actor is. -func TestTheAdminCheckWithoutAnAccountDatabaseTrustsOnlyTheLocalOwner(t *testing.T) { - check := addonAdminCheck(nil) - if !check(contract.Actor{UserID: "local-owner", Mode: "local-owner"}) { - t.Fatal("the local owner is not the administrator without an account database") - } - if check(contract.Actor{UserID: "u1", Mode: "named-user"}) { - t.Fatal("a named user is an administrator without an account database") - } -} +var _ = Describe("the admin check without an account database", func() { + It("trusts only the local owner", func() { + check := addonAdminCheck(nil) + Expect(check(contract.Actor{UserID: "local-owner", Mode: "local-owner"})).To(BeTrue(), + "the local owner is not the administrator without an account database") + Expect(check(contract.Actor{UserID: "u1", Mode: "named-user"})).To(BeFalse(), + "a named user is an administrator without an account database") + }) +}) diff --git a/core/application/addon_engine_jobs_test.go b/core/application/addon_engine_jobs_test.go index 5a35803cf..6387dcdd5 100644 --- a/core/application/addon_engine_jobs_test.go +++ b/core/application/addon_engine_jobs_test.go @@ -3,11 +3,12 @@ package application import ( "slices" - "testing" "github.com/mudler/LocalAI/core/services/addons/hostops" addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime" "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // Embedding the interfaces satisfies them; registration never calls a method. @@ -15,23 +16,19 @@ type noFineTune struct{ hostops.FineTuner } type noQuant struct{ hostops.Quantizer } type noBackends struct{ hostops.BackendLister } -func TestRegisterAddonEngineJobsWithoutABrokerRegistersNothing(t *testing.T) { - a := &Application{} - if err := a.RegisterAddonEngineJobs(noFineTune{}, noQuant{}, noBackends{}); err != nil { - t.Fatal(err) - } -} +var _ = Describe("RegisterAddonEngineJobs", func() { + It("registers nothing without a broker", func() { + a := &Application{} + Expect(a.RegisterAddonEngineJobs(noFineTune{}, noQuant{}, noBackends{})).To(Succeed()) + }) -func TestRegisterAddonEngineJobsRegistersThroughTheBroker(t *testing.T) { - b := hostops.New(hostops.NewInFlight(), nil, contract.DefaultLimits()) - a := &Application{addonHostOps: &addonruntime.HostOps{Broker: b}} - if err := a.RegisterAddonEngineJobs(noFineTune{}, noQuant{}, noBackends{}); err != nil { - t.Fatal(err) - } - ops := b.Ops() - for _, want := range []string{"finetune.start", "finetune.get", "finetune.list", "finetune.checkpoints", "quantize.start", "quantize.get", "quantize.list"} { - if !slices.Contains(ops, want) { - t.Fatalf("%s is not registered: %v", want, ops) + It("registers through the broker", func() { + b := hostops.New(hostops.NewInFlight(), nil, contract.DefaultLimits()) + a := &Application{addonHostOps: &addonruntime.HostOps{Broker: b}} + Expect(a.RegisterAddonEngineJobs(noFineTune{}, noQuant{}, noBackends{})).To(Succeed()) + ops := b.Ops() + for _, want := range []string{"finetune.start", "finetune.get", "finetune.list", "finetune.checkpoints", "quantize.start", "quantize.get", "quantize.list"} { + Expect(slices.Contains(ops, want)).To(BeTrue(), "%s is not registered: %v", want, ops) } - } -} + }) +}) diff --git a/core/application/addon_host_ops_auth_test.go b/core/application/addon_host_ops_auth_test.go index c1c1687d1..cb6226c48 100644 --- a/core/application/addon_host_ops_auth_test.go +++ b/core/application/addon_host_ops_auth_test.go @@ -5,35 +5,28 @@ package application import ( "path/filepath" - "testing" "github.com/mudler/LocalAI/core/http/auth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestTheFeatureCheckFollowsTheAccountPermissions(t *testing.T) { - db, err := auth.InitDB(filepath.Join(t.TempDir(), "auth.db")) - if err != nil { - t.Fatal(err) - } - mk := func(id, role, status string) { - if err := db.Create(&auth.User{ID: id, Email: id + "@example.com", Role: role, Status: status}).Error; err != nil { - t.Fatal(err) +var _ = Describe("the feature check", func() { + It("follows the account permissions", func() { + db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db")) + Expect(err).ToNot(HaveOccurred()) + mk := func(id, role, status string) { + Expect(db.Create(&auth.User{ID: id, Email: id + "@example.com", Role: role, Status: status}).Error).To(Succeed()) } - } - mk("admin", auth.RoleAdmin, auth.StatusActive) - mk("plain", auth.RoleUser, auth.StatusActive) - mk("granted", auth.RoleUser, auth.StatusActive) - mk("gone", auth.RoleAdmin, "disabled") - if err := auth.UpdateUserPermissions(db, "granted", auth.PermissionMap{"fine_tuning": true}); err != nil { - t.Fatal(err) - } - if err := auth.UpdateUserPermissions(db, "plain", auth.PermissionMap{"fine_tuning": false}); err != nil { - t.Fatal(err) - } - has := addonFeatureCheck(db) - for user, want := range map[string]bool{"admin": true, "granted": true, "plain": false, "gone": false, "nobody": false, "": false} { - if got := has(user, "fine_tuning"); got != want { - t.Errorf("%q: got %v want %v", user, got, want) + mk("admin", auth.RoleAdmin, auth.StatusActive) + mk("plain", auth.RoleUser, auth.StatusActive) + mk("granted", auth.RoleUser, auth.StatusActive) + mk("gone", auth.RoleAdmin, "disabled") + Expect(auth.UpdateUserPermissions(db, "granted", auth.PermissionMap{"fine_tuning": true})).To(Succeed()) + Expect(auth.UpdateUserPermissions(db, "plain", auth.PermissionMap{"fine_tuning": false})).To(Succeed()) + has := addonFeatureCheck(db) + for user, want := range map[string]bool{"admin": true, "granted": true, "plain": false, "gone": false, "nobody": false, "": false} { + Expect(has(user, "fine_tuning")).To(Equal(want), "user %q", user) } - } -} + }) +}) diff --git a/core/application/addon_host_ops_test.go b/core/application/addon_host_ops_test.go index e114a8fb1..4076613ad 100644 --- a/core/application/addon_host_ops_test.go +++ b/core/application/addon_host_ops_test.go @@ -2,34 +2,28 @@ package application import ( - "testing" - "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestAFrontendWithoutTheAddonProcessOffersNoHostOperations(t *testing.T) { - named := map[string][]string{"fine-tuning": {"finetune."}} - a := &Application{applicationConfig: &config.ApplicationConfig{AddonHostOps: named}} - if a.newAddonHostOps(nil, contract.DefaultLimits()) != nil { - t.Fatal("a process without the add-on manager built a broker") - } - if a.AddonHostOps() != nil { - t.Fatal("a process that never started the supervisor offers host operations") - } -} +var _ = Describe("add-on host operations", func() { + It("are not offered by a frontend without the add-on process", func() { + named := map[string][]string{"fine-tuning": {"finetune."}} + a := &Application{applicationConfig: &config.ApplicationConfig{AddonHostOps: named}} + Expect(a.newAddonHostOps(nil, contract.DefaultLimits())).To(BeNil(), "a process without the add-on manager built a broker") + Expect(a.AddonHostOps()).To(BeNil(), "a process that never started the supervisor offers host operations") + }) -func TestAHostThatNamesNoAddonOffersNoHostOperations(t *testing.T) { - for _, ops := range []map[string][]string{nil, {}} { - a := &Application{applicationConfig: &config.ApplicationConfig{AddonHostOps: ops}} - if a.newAddonHostOps(nil, contract.DefaultLimits()) != nil { - t.Fatalf("%v built a broker", ops) + It("are not offered by a host that names no add-on", func() { + for _, ops := range []map[string][]string{nil, {}} { + a := &Application{applicationConfig: &config.ApplicationConfig{AddonHostOps: ops}} + Expect(a.newAddonHostOps(nil, contract.DefaultLimits())).To(BeNil(), "%v built a broker", ops) } - } -} + }) -func TestTheFeatureCheckRefusesWithoutAnAccountDatabase(t *testing.T) { - if addonFeatureCheck(nil)("u1", "fine_tuning") { - t.Fatal("no database must mean no feature") - } -} + It("refuses the feature check without an account database", func() { + Expect(addonFeatureCheck(nil)("u1", "fine_tuning")).To(BeFalse(), "no database must mean no feature") + }) +}) diff --git a/core/application/addon_pin_store_internal_test.go b/core/application/addon_pin_store_internal_test.go index a01d8a7c7..b35467db4 100644 --- a/core/application/addon_pin_store_internal_test.go +++ b/core/application/addon_pin_store_internal_test.go @@ -5,33 +5,30 @@ import ( "bytes" "log/slog" "path/filepath" - "testing" "github.com/mudler/xlog" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func capture(t *testing.T) *bytes.Buffer { +func capture() *bytes.Buffer { var buf bytes.Buffer xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}), xlog.LogLevelDebug)) return &buf } -func TestMemoryOnlyPinsLogOneWarning(t *testing.T) { - buf := capture(t) - if _, err := pinStoreFor(nil, ""); err != nil { - t.Fatal(err) - } - if got := bytes.Count(buf.Bytes(), []byte("memory only")); got != 1 { - t.Fatalf("want one memory-only warning, got %d in %q", got, buf.String()) - } -} +var _ = Describe("pinStoreFor", func() { + It("logs one warning for memory-only pins", func() { + buf := capture() + _, err := pinStoreFor(nil, "") + Expect(err).ToNot(HaveOccurred()) + Expect(bytes.Count(buf.Bytes(), []byte("memory only"))).To(Equal(1), "log: %q", buf.String()) + }) -func TestPinsFileDoesNotWarn(t *testing.T) { - buf := capture(t) - if _, err := pinStoreFor(nil, filepath.Join(t.TempDir(), "addon-pins.json")); err != nil { - t.Fatal(err) - } - if buf.Len() != 0 { - t.Fatalf("unexpected log: %q", buf.String()) - } -} + It("does not warn with a pins file", func() { + buf := capture() + _, err := pinStoreFor(nil, filepath.Join(GinkgoT().TempDir(), "addon-pins.json")) + Expect(err).ToNot(HaveOccurred()) + Expect(buf.Len()).To(BeZero(), "unexpected log: %q", buf.String()) + }) +}) diff --git a/core/application/addon_pin_sweep_test.go b/core/application/addon_pin_sweep_test.go index 4e93d3f77..12366f5bd 100644 --- a/core/application/addon_pin_sweep_test.go +++ b/core/application/addon_pin_sweep_test.go @@ -5,11 +5,12 @@ import ( "context" "errors" "slices" - "testing" "time" "github.com/mudler/LocalAI/core/services/addons/builtin" "github.com/mudler/LocalAI/core/services/addons/pins" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type fakeOwner struct { @@ -19,12 +20,9 @@ type fakeOwner struct { func (f fakeOwner) InstalledIDs(context.Context) ([]string, error) { return f.ids, f.err } -func pinnedIDs(t *testing.T, s pins.Store, user string) []string { - t.Helper() +func pinnedIDs(s pins.Store, user string) []string { list, err := s.List(context.Background(), user) - if err != nil { - t.Fatal(err) - } + Expect(err).ToNot(HaveOccurred()) out := []string{} for _, p := range list { out = append(out, p.AddonID) @@ -32,35 +30,35 @@ func pinnedIDs(t *testing.T, s pins.Store, user string) []string { return out } -func TestSweepNeverDeletesTheBuiltinPins(t *testing.T) { - // No page is built in today, so the test makes up two. - defer builtin.SetCatalogForTest([]builtin.Entry{ - {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"}, - {ID: "help", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"}, - })() - ctx := context.Background() - s, _ := pins.NewFileStore("") - _ = s.Replace(ctx, "u1", []string{"guide", "help", "notes"}) - t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) - owner := fakeOwner{ids: []string{}} - sweepAddonPins(ctx, s, owner, t0) - sweepAddonPins(ctx, s, owner, t0.Add(pins.Retention+time.Hour)) - // notes is not installed and expired; the built-ins are always present. - if got := pinnedIDs(t, s, "u1"); !slices.Equal(got, []string{"guide", "help"}) { - t.Fatalf("unexpected pins %v", got) - } -} +var _ = Describe("sweepAddonPins", func() { + It("never deletes the built-in pins", func() { + // No page is built in today, so the test makes up two. + DeferCleanup(builtin.SetCatalogForTest([]builtin.Entry{ + {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"}, + {ID: "help", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"}, + })) + ctx := context.Background() + s, _ := pins.NewFileStore("") + _ = s.Replace(ctx, "u1", []string{"guide", "help", "notes"}) + t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + owner := fakeOwner{ids: []string{}} + sweepAddonPins(ctx, s, owner, t0) + sweepAddonPins(ctx, s, owner, t0.Add(pins.Retention+time.Hour)) + // notes is not installed and expired; the built-ins are always present. + got := pinnedIDs(s, "u1") + Expect(slices.Equal(got, []string{"guide", "help"})).To(BeTrue(), "unexpected pins %v", got) + }) -func TestSweepIsSkippedWhenTheOwnerDoesNotAnswer(t *testing.T) { - ctx := context.Background() - s, _ := pins.NewFileStore("") - _ = s.Replace(ctx, "u1", []string{"notes"}) - t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) - owner := fakeOwner{err: errors.New("owner unavailable")} - sweepAddonPins(ctx, s, owner, t0) - sweepAddonPins(ctx, s, owner, t0.Add(pins.Retention+time.Hour)) - list, _ := s.List(ctx, "u1") - if len(list) != 1 || list[0].MissingSince != nil { - t.Fatalf("a sweep without an answer must neither mark nor delete: %+v", list) - } -} + It("is skipped when the owner does not answer", func() { + ctx := context.Background() + s, _ := pins.NewFileStore("") + _ = s.Replace(ctx, "u1", []string{"notes"}) + t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + owner := fakeOwner{err: errors.New("owner unavailable")} + sweepAddonPins(ctx, s, owner, t0) + sweepAddonPins(ctx, s, owner, t0.Add(pins.Retention+time.Hour)) + list, _ := s.List(ctx, "u1") + Expect(list).To(HaveLen(1), "a sweep without an answer must neither mark nor delete: %+v", list) + Expect(list[0].MissingSince).To(BeNil()) + }) +}) diff --git a/core/cli/run_addon_host_ops_test.go b/core/cli/run_addon_host_ops_test.go index e67dbad2d..bab4bffe6 100644 --- a/core/cli/run_addon_host_ops_test.go +++ b/core/cli/run_addon_host_ops_test.go @@ -3,20 +3,17 @@ package cli import ( "reflect" - "testing" "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestTheAddonHostOpsFlagDefaultIsTheDocumentedSetting(t *testing.T) { - f, ok := reflect.TypeOf(RunCMD{}).FieldByName("AddonHostOps") - if !ok { - t.Fatal("the flag is missing") - } - if got := f.Tag.Get("default"); got != config.DefaultAddonHostOps { - t.Fatalf("flag default %q differs from %q", got, config.DefaultAddonHostOps) - } - if f.Tag.Get("env") != "LOCALAI_ADDON_HOST_OPS" { - t.Fatalf("env %q", f.Tag.Get("env")) - } -} +var _ = Describe("the add-on host operations flag", func() { + It("defaults to the documented setting", func() { + f, ok := reflect.TypeOf(RunCMD{}).FieldByName("AddonHostOps") + Expect(ok).To(BeTrue(), "the flag is missing") + Expect(f.Tag.Get("default")).To(Equal(config.DefaultAddonHostOps)) + Expect(f.Tag.Get("env")).To(Equal("LOCALAI_ADDON_HOST_OPS")) + }) +}) diff --git a/core/config/addon_host_ops_test.go b/core/config/addon_host_ops_test.go index 2cf0ae9c0..3b59a6160 100644 --- a/core/config/addon_host_ops_test.go +++ b/core/config/addon_host_ops_test.go @@ -2,37 +2,49 @@ package config import ( - "reflect" - "testing" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestParseAddonHostOps(t *testing.T) { - got, err := ParseAddonHostOps(DefaultAddonHostOps) - want := map[string][]string{"fine-tuning": {"finetune."}, "quantization": {"quantize."}} - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("%v %v", got, err) - } - got, err = ParseAddonHostOps(" a-b = x. , a-b=y. ,c=z.") - if err != nil || !reflect.DeepEqual(got, map[string][]string{"a-b": {"x.", "y."}, "c": {"z."}}) { - t.Fatalf("%v %v", got, err) - } - if got, err := ParseAddonHostOps(""); err != nil || len(got) != 0 { - t.Fatalf("an empty setting names nobody: %v %v", got, err) - } - for _, bad := range []string{"fine-tuning", "=finetune.", "fine-tuning=", "fine-tuning=finetune", "Fine=finetune.", "fine-tuning=Finetune.", "a/b=finetune."} { - if _, err := ParseAddonHostOps(bad); err == nil { - t.Errorf("%q: accepted", bad) - } - } -} +var _ = Describe("ParseAddonHostOps", func() { + It("parses the default setting", func() { + got, err := ParseAddonHostOps(DefaultAddonHostOps) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(map[string][]string{"fine-tuning": {"finetune."}, "quantization": {"quantize."}})) + }) -func TestWithAddonHostOpsReplacesTheDefault(t *testing.T) { - o := &ApplicationConfig{} - if o.AddonHostOps != nil { - t.Fatal("the field starts unset") - } - WithAddonHostOps(map[string][]string{"x": {"y."}})(o) - if len(o.AddonHostOps) != 1 { - t.Fatalf("%v", o.AddonHostOps) - } -} + It("trims blanks and merges repeated names", func() { + got, err := ParseAddonHostOps(" a-b = x. , a-b=y. ,c=z.") + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(map[string][]string{"a-b": {"x.", "y."}, "c": {"z."}})) + }) + + It("names nobody for an empty setting", func() { + got, err := ParseAddonHostOps("") + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty()) + }) + + DescribeTable("rejects a malformed setting", + func(bad string) { + _, err := ParseAddonHostOps(bad) + Expect(err).To(HaveOccurred(), "%q: accepted", bad) + }, + Entry("no operations", "fine-tuning"), + Entry("no add-on name", "=finetune."), + Entry("empty prefix", "fine-tuning="), + Entry("prefix without a dot", "fine-tuning=finetune"), + Entry("uppercase add-on name", "Fine=finetune."), + Entry("uppercase prefix", "fine-tuning=Finetune."), + Entry("slash in the name", "a/b=finetune."), + ) +}) + +var _ = Describe("WithAddonHostOps", func() { + It("replaces the default", func() { + o := &ApplicationConfig{} + Expect(o.AddonHostOps).To(BeNil(), "the field starts unset") + WithAddonHostOps(map[string][]string{"x": {"y."}})(o) + Expect(o.AddonHostOps).To(HaveLen(1)) + }) +}) diff --git a/core/http/endpoints/addons/artifacts_test.go b/core/http/endpoints/addons/artifacts_test.go index a70d3202c..00f6ceb1f 100644 --- a/core/http/endpoints/addons/artifacts_test.go +++ b/core/http/endpoints/addons/artifacts_test.go @@ -167,7 +167,7 @@ func fetch(url string, mutate func(*http.Request)) fetched { client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) Expect(err).NotTo(HaveOccurred()) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() b, rerr := io.ReadAll(resp.Body) return fetched{code: resp.StatusCode, header: resp.Header, body: string(b), err: rerr} } diff --git a/core/http/endpoints/addons/builtin_test.go b/core/http/endpoints/addons/builtin_test.go index 55bdda7d7..4a3d1d01b 100644 --- a/core/http/endpoints/addons/builtin_test.go +++ b/core/http/endpoints/addons/builtin_test.go @@ -5,29 +5,25 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "testing" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/services/addons/builtin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func listBuiltin(t *testing.T) (*httptest.ResponseRecorder, []string) { - t.Helper() +func listBuiltin() (*httptest.ResponseRecorder, []string) { e := echo.New() e.GET("/api/addons/builtin", ListBuiltin(nil)) rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/addons/builtin", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } + Expect(rec.Code).To(Equal(http.StatusOK)) var body struct { Builtin []struct { ID string `json:"id"` } `json:"builtin"` } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) ids := []string{} for _, e := range body.Builtin { ids = append(ids, e.ID) @@ -35,26 +31,22 @@ func listBuiltin(t *testing.T) (*httptest.ResponseRecorder, []string) { return rec, ids } -func TestListBuiltinIsEmptyBecauseStudioAndTalkArePackagedAddOns(t *testing.T) { - rec, ids := listBuiltin(t) - if len(ids) != 0 { - t.Fatalf("no page is built in, got %v", ids) - } - if rec.Header().Get("Cache-Control") != "private, no-store" { - t.Fatalf("the list depends on the caller and must not be cached, got %q", rec.Header().Get("Cache-Control")) - } -} +var _ = Describe("ListBuiltin", func() { + It("is empty because Studio and Talk are packaged add-ons", func() { + rec, ids := listBuiltin() + Expect(ids).To(BeEmpty(), "no page is built in") + Expect(rec.Header().Get("Cache-Control")).To(Equal("private, no-store"), + "the list depends on the caller and must not be cached") + }) -func TestListBuiltinWithoutAnAccountDatabaseListsEveryEntry(t *testing.T) { - defer builtin.SetCatalogForTest([]builtin.Entry{ - {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"}, - {ID: "notes-page", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"}, - })() - rec, ids := listBuiltin(t) - if len(ids) != 2 || ids[0] != "guide" || ids[1] != "notes-page" { - t.Fatalf("unexpected body %s", rec.Body.String()) - } - if rec.Header().Get("Cache-Control") != "private, no-store" { - t.Fatalf("the list depends on the caller and must not be cached, got %q", rec.Header().Get("Cache-Control")) - } -} + It("lists every entry without an account database", func() { + DeferCleanup(builtin.SetCatalogForTest([]builtin.Entry{ + {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"}, + {ID: "notes-page", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"}, + })) + rec, ids := listBuiltin() + Expect(ids).To(Equal([]string{"guide", "notes-page"}), "unexpected body %s", rec.Body.String()) + Expect(rec.Header().Get("Cache-Control")).To(Equal("private, no-store"), + "the list depends on the caller and must not be cached") + }) +}) diff --git a/core/http/endpoints/addons/forwarding_test.go b/core/http/endpoints/addons/forwarding_test.go index bca1a4abd..ba048b486 100644 --- a/core/http/endpoints/addons/forwarding_test.go +++ b/core/http/endpoints/addons/forwarding_test.go @@ -121,7 +121,7 @@ var _ = Describe("alternating authenticated frontends", func() { resp, e := client.Do(req) Expect(e).NotTo(HaveOccurred()) b, e := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() Expect(e).NotTo(HaveOccurred()) Expect(resp.StatusCode).To(Equal(200)) var result contract.Result @@ -134,7 +134,7 @@ var _ = Describe("alternating authenticated frontends", func() { req.Header.Set("Authorization", "Bearer "+token) resp, e := client.Do(req) Expect(e).NotTo(HaveOccurred()) - resp.Body.Close() + _ = resp.Body.Close() Expect(resp.StatusCode).To(Equal(401)) }) }) diff --git a/core/http/endpoints/addons/pins_test.go b/core/http/endpoints/addons/pins_test.go index 7fcec17d8..0d8a5ef64 100644 --- a/core/http/endpoints/addons/pins_test.go +++ b/core/http/endpoints/addons/pins_test.go @@ -7,19 +7,19 @@ import ( "net/http" "net/http/httptest" "strings" - "testing" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/addons/builtin" "github.com/mudler/LocalAI/core/services/addons/pins" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // No page is built in today, so these tests make up two built-in pages to check // how the routes filter and keep pins. -func madeUpPages(t *testing.T) { - t.Helper() - t.Cleanup(builtin.SetCatalogForTest([]builtin.Entry{ +func madeUpPages() { + DeferCleanup(builtin.SetCatalogForTest([]builtin.Entry{ {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"}, {ID: "help", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"}, })) @@ -28,16 +28,13 @@ func madeUpPages(t *testing.T) { // pinsApp builds the two routes over a store. With seeded set, the upgrade seed // has run for the local owner, as it does at start in a deployment that // existed before this release. -func pinsApp(t *testing.T, seeded bool) *echo.Echo { - madeUpPages(t) +func pinsApp(seeded bool) *echo.Echo { + madeUpPages() store, err := pins.NewFileStore("") - if err != nil { - t.Fatal(err) - } + Expect(err).ToNot(HaveOccurred()) if seeded { - if _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, []string{"guide", "help"}); err != nil { - t.Fatal(err) - } + _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, []string{"guide", "help"}) + Expect(err).ToNot(HaveOccurred()) } svc := &pins.Service{Store: store} e := echo.New() @@ -56,64 +53,62 @@ func call(e *echo.Echo, method, body string) (int, []string) { return rec.Code, out } -func TestAnAccountCreatedAfterTheUpgradeGetsNoPins(t *testing.T) { - e := pinsApp(t, false) - code, got := call(e, http.MethodGet, "") - if code != 200 || len(got) != 0 { - t.Fatalf("no seed has run for this account, so it has no pins: %d %v", code, got) - } -} +var _ = Describe("the pins routes", func() { + It("gives no pins to an account created after the upgrade", func() { + e := pinsApp(false) + code, got := call(e, http.MethodGet, "") + Expect(code).To(Equal(200), "no seed has run for this account, so it has no pins") + Expect(got).To(BeEmpty()) + }) -func TestPinsRoundTripForTheLocalOwner(t *testing.T) { - e := pinsApp(t, true) - code, got := call(e, http.MethodGet, "") - if code != 200 || len(got) != 2 || got[0] != "guide" || got[1] != "help" { - t.Fatalf("seed: %d %v", code, got) - } - code, got = call(e, http.MethodPut, `["help","guide"]`) - if code != 200 || got[0] != "help" { - t.Fatalf("put: %d %v", code, got) - } - _, got = call(e, http.MethodGet, "") - if len(got) != 2 || got[0] != "help" { - t.Fatalf("round trip: %v", got) - } -} + It("round-trips pins for the local owner", func() { + e := pinsApp(true) + code, got := call(e, http.MethodGet, "") + Expect(code).To(Equal(200)) + Expect(got).To(Equal([]string{"guide", "help"}), "seed") + code, got = call(e, http.MethodPut, `["help","guide"]`) + Expect(code).To(Equal(200)) + Expect(got).To(HaveLen(2)) + Expect(got[0]).To(Equal("help"), "put") + _, got = call(e, http.MethodGet, "") + Expect(got).To(HaveLen(2)) + Expect(got[0]).To(Equal("help"), "round trip") + }) -func TestPutDropsIDsTheCallerCannotSee(t *testing.T) { - e := pinsApp(t, false) - _, got := call(e, http.MethodPut, `["guide","not-installed"]`) - if len(got) != 1 || got[0] != "guide" { - t.Fatalf("an unknown id must be dropped without a hint: %v", got) - } -} + It("drops IDs the caller cannot see", func() { + e := pinsApp(false) + _, got := call(e, http.MethodPut, `["guide","not-installed"]`) + Expect(got).To(Equal([]string{"guide"}), "an unknown id must be dropped without a hint") + }) -func TestPutRejectsABodyThatIsNotAListOfStrings(t *testing.T) { - e := pinsApp(t, false) - for _, body := range []string{`{"a":1}`, `[1,2]`, `not json`, ``, `null`} { - if code, _ := call(e, http.MethodPut, body); code != 400 { - t.Fatalf("body %q: want 400, got %d", body, code) + DescribeTable("rejects a body that is not a list of strings", + func(body string) { + e := pinsApp(false) + code, _ := call(e, http.MethodPut, body) + Expect(code).To(Equal(400), "body %q", body) + }, + Entry("an object", `{"a":1}`), + Entry("numbers", `[1,2]`), + Entry("not JSON", `not json`), + Entry("empty", ``), + Entry("null", `null`), + ) + + It("rejects a list over the hard limit", func() { + e := pinsApp(false) + items := make([]string, pins.HardLimit+1) + for i := range items { + items[i] = `"guide"` } - } -} + code, _ := call(e, http.MethodPut, "["+strings.Join(items, ",")+"]") + Expect(code).To(Equal(400)) + }) -func TestPutRejectsAListOverTheHardLimit(t *testing.T) { - e := pinsApp(t, false) - items := make([]string, pins.HardLimit+1) - for i := range items { - items[i] = `"guide"` - } - if code, _ := call(e, http.MethodPut, "["+strings.Join(items, ",")+"]"); code != 400 { - t.Fatalf("want 400, got %d", code) - } -} - -func TestStudioAndTalkAreNotVisiblePinsWithoutTheAddOns(t *testing.T) { - // Studio and Talk are packaged add-ons: the routes list them only for a - // caller whose owner lists them as installed, never as built-in pages. - e := pinsApp(t, false) - _, got := call(e, http.MethodPut, `["studio","talk","guide"]`) - if len(got) != 1 || got[0] != "guide" { - t.Fatalf("studio and talk are not built in, so they are dropped: %v", got) - } -} + It("does not show Studio and Talk as pins without the add-ons", func() { + // Studio and Talk are packaged add-ons: the routes list them only for a + // caller whose owner lists them as installed, never as built-in pages. + e := pinsApp(false) + _, got := call(e, http.MethodPut, `["studio","talk","guide"]`) + Expect(got).To(Equal([]string{"guide"}), "studio and talk are not built in, so they are dropped") + }) +}) diff --git a/core/http/endpoints/addons/tools_test.go b/core/http/endpoints/addons/tools_test.go index c9050b150..081fa2be1 100644 --- a/core/http/endpoints/addons/tools_test.go +++ b/core/http/endpoints/addons/tools_test.go @@ -137,7 +137,7 @@ var _ = Describe("server-side tool declarations over HTTP", Label("tools"), func req.Header.Set("Content-Type", "application/json") resp, e := hc.Do(req) Expect(e).NotTo(HaveOccurred()) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(b) } diff --git a/core/http/endpoints/localai/addon_engine_models_test.go b/core/http/endpoints/localai/addon_engine_models_test.go index 5c87dcaf7..55698a1e2 100644 --- a/core/http/endpoints/localai/addon_engine_models_test.go +++ b/core/http/endpoints/localai/addon_engine_models_test.go @@ -3,11 +3,12 @@ package localai import ( "context" "errors" - "testing" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/services/addons/hostops" pb "github.com/mudler/LocalAI/pkg/grpc/proto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type fakeFTJobs struct { @@ -55,72 +56,68 @@ func (f *fakeQJobs) ImportModel(context.Context, string, string, schema.Quantiza var errNoJob = errors.New("job not found: j9") -func TestFineTuneModelsMapsAMissingJobToNotFound(t *testing.T) { - f := FineTuneModels{Svc: &fakeFTJobs{getErr: errNoJob}} - if _, err := f.ExportModel(context.Background(), "u1", "j9", schema.ExportRequest{}); !errors.Is(err, hostops.ErrNotFound) || !errors.Is(err, errNoJob) { - t.Fatalf("export: got %v, want not found", err) - } - if _, err := f.Checkpoints(context.Background(), "u1", "j9"); !errors.Is(err, hostops.ErrNotFound) { - t.Fatalf("checkpoints: got %v, want not found", err) - } -} +var _ = Describe("FineTuneModels", func() { + It("maps a missing job to not found", func() { + f := FineTuneModels{Svc: &fakeFTJobs{getErr: errNoJob}} + _, err := f.ExportModel(context.Background(), "u1", "j9", schema.ExportRequest{}) + Expect(err).To(MatchError(hostops.ErrNotFound), "export") + Expect(err).To(MatchError(errNoJob), "export") + _, err = f.Checkpoints(context.Background(), "u1", "j9") + Expect(err).To(MatchError(hostops.ErrNotFound), "checkpoints") + }) -func TestFineTuneModelsRefusesAnExportThatIsRunningAsAConflict(t *testing.T) { - svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1", ExportStatus: "exporting"}, exported: "/models/m"} - f := FineTuneModels{Svc: svc} - if _, err := f.ExportModel(context.Background(), "u1", "j1", schema.ExportRequest{}); !errors.Is(err, hostops.ErrConflict) { - t.Fatalf("got %v, want conflict", err) - } - if svc.exports != 0 { - t.Fatalf("service export ran %d times for a running export", svc.exports) - } -} + It("refuses an export that is running as a conflict", func() { + svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1", ExportStatus: "exporting"}, exported: "/models/m"} + f := FineTuneModels{Svc: svc} + _, err := f.ExportModel(context.Background(), "u1", "j1", schema.ExportRequest{}) + Expect(err).To(MatchError(hostops.ErrConflict)) + Expect(svc.exports).To(BeZero(), "service export ran for a running export") + }) -func TestFineTuneModelsPassesAFinishedJobToTheService(t *testing.T) { - svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1", ExportStatus: ""}, exported: "/models/m"} - f := FineTuneModels{Svc: svc} - got, err := f.ExportModel(context.Background(), "u1", "j1", schema.ExportRequest{}) - if err != nil || got != "/models/m" || svc.exports != 1 { - t.Fatalf("got %q %v exports=%d", got, err, svc.exports) - } -} + It("passes a finished job to the service", func() { + svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1", ExportStatus: ""}, exported: "/models/m"} + f := FineTuneModels{Svc: svc} + got, err := f.ExportModel(context.Background(), "u1", "j1", schema.ExportRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("/models/m")) + Expect(svc.exports).To(Equal(1)) + }) -func TestFineTuneModelsListsCheckpointsWithTheirPaths(t *testing.T) { - svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1"}, cps: []*pb.CheckpointInfo{{Step: 7, Path: "/out/ckpt-7"}}} - got, err := FineTuneModels{Svc: svc}.Checkpoints(context.Background(), "u1", "j1") - if err != nil || len(got) != 1 || got[0].Step != 7 || got[0].Path != "/out/ckpt-7" { - t.Fatalf("got %+v %v", got, err) - } -} + It("lists checkpoints with their paths", func() { + svc := &fakeFTJobs{job: &schema.FineTuneJob{ID: "j1"}, cps: []*pb.CheckpointInfo{{Step: 7, Path: "/out/ckpt-7"}}} + got, err := FineTuneModels{Svc: svc}.Checkpoints(context.Background(), "u1", "j1") + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + Expect(got[0].Step).To(BeEquivalentTo(7)) + Expect(got[0].Path).To(Equal("/out/ckpt-7")) + }) +}) -func TestQuantizationModelsMapsAMissingJobToNotFound(t *testing.T) { - q := QuantizationModels{Svc: &fakeQJobs{getErr: errNoJob}} - if _, err := q.ImportModel(context.Background(), "u1", "j9", schema.QuantizationImportRequest{}); !errors.Is(err, hostops.ErrNotFound) { - t.Fatalf("got %v, want not found", err) - } -} +var _ = Describe("QuantizationModels", func() { + It("maps a missing job to not found", func() { + q := QuantizationModels{Svc: &fakeQJobs{getErr: errNoJob}} + _, err := q.ImportModel(context.Background(), "u1", "j9", schema.QuantizationImportRequest{}) + Expect(err).To(MatchError(hostops.ErrNotFound)) + }) -func TestQuantizationModelsRefusesAJobThatIsNotCompletedAsAConflict(t *testing.T) { - svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "running"}, imported: "/models/q"} - if _, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}); !errors.Is(err, hostops.ErrConflict) { - t.Fatalf("got %v, want conflict", err) - } - if svc.imports != 0 { - t.Fatalf("service import ran %d times for an unfinished job", svc.imports) - } -} + It("refuses a job that is not completed as a conflict", func() { + svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "running"}, imported: "/models/q"} + _, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}) + Expect(err).To(MatchError(hostops.ErrConflict)) + Expect(svc.imports).To(BeZero(), "service import ran for an unfinished job") + }) -func TestQuantizationModelsRefusesAnImportThatIsRunningAsAConflict(t *testing.T) { - svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "completed", ImportStatus: "importing"}, imported: "/models/q"} - if _, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}); !errors.Is(err, hostops.ErrConflict) { - t.Fatalf("got %v, want conflict", err) - } -} + It("refuses an import that is running as a conflict", func() { + svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "completed", ImportStatus: "importing"}, imported: "/models/q"} + _, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}) + Expect(err).To(MatchError(hostops.ErrConflict)) + }) -func TestQuantizationModelsPassesACompletedJobToTheService(t *testing.T) { - svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "completed"}, imported: "/models/q"} - got, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}) - if err != nil || got != "/models/q" || svc.imports != 1 { - t.Fatalf("got %q %v imports=%d", got, err, svc.imports) - } -} + It("passes a completed job to the service", func() { + svc := &fakeQJobs{job: &schema.QuantizationJob{ID: "q1", Status: "completed"}, imported: "/models/q"} + got, err := (QuantizationModels{Svc: svc}).ImportModel(context.Background(), "u1", "q1", schema.QuantizationImportRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("/models/q")) + Expect(svc.imports).To(Equal(1)) + }) +}) diff --git a/core/services/addons/builtin/builtin_suite_test.go b/core/services/addons/builtin/builtin_suite_test.go new file mode 100644 index 000000000..b5d6ab545 --- /dev/null +++ b/core/services/addons/builtin/builtin_suite_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +package builtin + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBuiltin(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Built-in add-ons suite") +} diff --git a/core/services/addons/builtin/builtin_test.go b/core/services/addons/builtin/builtin_test.go index e5242e97f..f42076620 100644 --- a/core/services/addons/builtin/builtin_test.go +++ b/core/services/addons/builtin/builtin_test.go @@ -3,7 +3,9 @@ package builtin import ( "slices" - "testing" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func ids(es []Entry) []string { @@ -14,51 +16,44 @@ func ids(es []Entry) []string { return out } -func TestNoAddonIsBuiltIn(t *testing.T) { - // Studio and Talk are packaged add-ons, installed and removed like the - // others, so nothing is listed for a viewer, whatever the viewer holds. - if got := ids(Visible(Viewer{Admin: true})); len(got) != 0 { - t.Fatalf("no add-on is built in, got %v", got) - } - if got := ids(Visible(Viewer{Has: func(string) bool { return true }})); len(got) != 0 { - t.Fatalf("no add-on is built in, got %v", got) - } - if len(IDs()) != 0 || len(Catalog()) != 0 { - t.Fatalf("the catalog must be empty, got %v", IDs()) - } -} +var _ = ginkgo.Describe("the built-in catalog", func() { + ginkgo.It("holds no add-on", func() { + // Studio and Talk are packaged add-ons, installed and removed like the + // others, so nothing is listed for a viewer, whatever the viewer holds. + Expect(ids(Visible(Viewer{Admin: true}))).To(BeEmpty(), "no add-on is built in") + Expect(ids(Visible(Viewer{Has: func(string) bool { return true }}))).To(BeEmpty(), "no add-on is built in") + Expect(IDs()).To(BeEmpty(), "the catalog must be empty") + Expect(Catalog()).To(BeEmpty(), "the catalog must be empty") + }) -func TestStudioAndTalkAreNotBuiltIn(t *testing.T) { - for _, id := range []string{"studio", "talk"} { - if slices.Contains(IDs(), id) { - t.Fatalf("%s is a packaged add-on and must not be in the built-in catalog", id) + ginkgo.DescribeTable("does not list a packaged add-on", + func(id string) { + Expect(slices.Contains(IDs(), id)).To(BeFalse(), "%s is a packaged add-on and must not be in the built-in catalog", id) + }, + ginkgo.Entry("studio", "studio"), + ginkgo.Entry("talk", "talk"), + ) + + ginkgo.It("hides a gated entry from a viewer without the feature", func() { + gated := Entry{ID: "x", Feature: "fine_tuning"} + adminOnly := Entry{ID: "y", AdminOnly: true} + none := Viewer{Has: func(string) bool { return false }} + Expect(none.allows(gated)).To(BeFalse(), "a viewer without the feature must not see a gated entry") + Expect(none.allows(adminOnly)).To(BeFalse(), "a viewer without the role must not see a gated entry") + has := Viewer{Has: func(f string) bool { return f == "fine_tuning" }} + Expect(has.allows(gated)).To(BeTrue(), "the feature opens a feature gate") + Expect(has.allows(adminOnly)).To(BeFalse(), "the feature opens a feature gate and nothing else") + admin := Viewer{Admin: true} + Expect(admin.allows(gated)).To(BeTrue(), "an administrator passes every gate") + Expect(admin.allows(adminOnly)).To(BeTrue(), "an administrator passes every gate") + }) + + ginkgo.It("has unique and well-formed IDs", func() { + seen := map[string]bool{} + for _, e := range Catalog() { + Expect(seen[e.ID] || e.ID == "" || e.TitleKey == "" || e.MainPath == "" || e.Kind != KindPage).To(BeFalse(), + "invalid or duplicate entry %+v", e) + seen[e.ID] = true } - } -} - -func TestGatesHideAnEntryFromAViewerWithoutTheFeature(t *testing.T) { - gated := Entry{ID: "x", Feature: "fine_tuning"} - adminOnly := Entry{ID: "y", AdminOnly: true} - none := Viewer{Has: func(string) bool { return false }} - if none.allows(gated) || none.allows(adminOnly) { - t.Fatal("a viewer without the feature or the role must not see a gated entry") - } - has := Viewer{Has: func(f string) bool { return f == "fine_tuning" }} - if !has.allows(gated) || has.allows(adminOnly) { - t.Fatal("the feature opens a feature gate and nothing else") - } - admin := Viewer{Admin: true} - if !admin.allows(gated) || !admin.allows(adminOnly) { - t.Fatal("an administrator passes every gate") - } -} - -func TestIDsAreUniqueAndWellFormed(t *testing.T) { - seen := map[string]bool{} - for _, e := range Catalog() { - if seen[e.ID] || e.ID == "" || e.TitleKey == "" || e.MainPath == "" || e.Kind != KindPage { - t.Fatalf("invalid or duplicate entry %+v", e) - } - seen[e.ID] = true - } -} + }) +}) diff --git a/core/services/addons/gateway/loopback_test.go b/core/services/addons/gateway/loopback_test.go index 3007fb524..ee28bb8e5 100644 --- a/core/services/addons/gateway/loopback_test.go +++ b/core/services/addons/gateway/loopback_test.go @@ -6,102 +6,70 @@ import ( "os" "path/filepath" "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestLoopbackAddressIsStableAcrossStarts(t *testing.T) { - root := filepath.Join(t.TempDir(), "addons") - _, first, err := NewLoopbackCarrierConfig("local", root) - if err != nil { - t.Fatal(err) - } - cfg, second, err := NewLoopbackCarrierConfig("local", root) - if err != nil { - t.Fatal(err) - } - if first != second { - t.Fatalf("address changed between starts: %s then %s", first, second) - } - if cfg.OwnerAddress != "https://"+second { - t.Fatalf("owner address %q does not match listen address %q", cfg.OwnerAddress, second) - } - info, err := os.Stat(filepath.Join(root, loopbackPortFile)) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Fatalf("port file mode is %v, want 0600", info.Mode().Perm()) - } -} +var _ = Describe("the loopback carrier config", func() { + It("keeps its address stable across starts", func() { + root := filepath.Join(GinkgoT().TempDir(), "addons") + _, first, err := NewLoopbackCarrierConfig("local", root) + Expect(err).ToNot(HaveOccurred()) + cfg, second, err := NewLoopbackCarrierConfig("local", root) + Expect(err).ToNot(HaveOccurred()) + Expect(second).To(Equal(first), "address changed between starts") + Expect(cfg.OwnerAddress).To(Equal("https://"+second), "owner address does not match listen address") + info, err := os.Stat(filepath.Join(root, loopbackPortFile)) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600)), "port file mode") + }) -func TestLoopbackDifferentRootsDoNotShareState(t *testing.T) { - _, a, err := NewLoopbackCarrierConfig("local", filepath.Join(t.TempDir(), "addons")) - if err != nil { - t.Fatal(err) - } - if _, _, err := NewLoopbackCarrierConfig("local", filepath.Join(t.TempDir(), "addons")); err != nil { - t.Fatal(err) - } - if a == "" { - t.Fatal("empty address") - } -} + It("does not share state between different roots", func() { + _, a, err := NewLoopbackCarrierConfig("local", filepath.Join(GinkgoT().TempDir(), "addons")) + Expect(err).ToNot(HaveOccurred()) + _, _, err = NewLoopbackCarrierConfig("local", filepath.Join(GinkgoT().TempDir(), "addons")) + Expect(err).ToNot(HaveOccurred()) + Expect(a).ToNot(BeEmpty(), "empty address") + }) -func TestLoopbackPortInUseFailsClearly(t *testing.T) { - root := filepath.Join(t.TempDir(), "addons") - _, address, err := NewLoopbackCarrierConfig("local", root) - if err != nil { - t.Fatal(err) - } - held, err := net.Listen("tcp", address) - if err != nil { - t.Fatal(err) - } - defer func() { _ = held.Close() }() - _, _, err = NewLoopbackCarrierConfig("local", root) - if err == nil { - t.Fatal("expected an error while the port is in use") - } - _, port, _ := net.SplitHostPort(address) - if !strings.Contains(err.Error(), port) || !strings.Contains(err.Error(), "--addons-enabled=false") { - t.Fatalf("error does not name the port and the option: %v", err) - } -} + It("fails clearly when the port is in use", func() { + root := filepath.Join(GinkgoT().TempDir(), "addons") + _, address, err := NewLoopbackCarrierConfig("local", root) + Expect(err).ToNot(HaveOccurred()) + held, err := net.Listen("tcp", address) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = held.Close() }() + _, _, err = NewLoopbackCarrierConfig("local", root) + Expect(err).To(HaveOccurred(), "expected an error while the port is in use") + _, port, _ := net.SplitHostPort(address) + Expect(err.Error()).To(ContainSubstring(port), "error does not name the port") + Expect(err.Error()).To(ContainSubstring("--addons-enabled=false"), "error does not name the option") + }) -func TestLoopbackRejectsInvalidPortFile(t *testing.T) { - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, loopbackPortFile), []byte("nope"), 0o600); err != nil { - t.Fatal(err) - } - if _, _, err := NewLoopbackCarrierConfig("local", root); err == nil { - t.Fatal("expected an error for an invalid port file") - } -} + It("rejects an invalid port file", func() { + root := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(root, loopbackPortFile), []byte("nope"), 0o600)).To(Succeed()) + _, _, err := NewLoopbackCarrierConfig("local", root) + Expect(err).To(HaveOccurred(), "expected an error for an invalid port file") + }) -func TestLoopbackRejectsLoosePortFileMode(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, loopbackPortFile) - if err := os.WriteFile(path, []byte("40123\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, 0o644); err != nil { - t.Fatal(err) - } - if _, _, err := NewLoopbackCarrierConfig("local", root); err == nil || !strings.Contains(err.Error(), "0600") { - t.Fatalf("expected a mode error, got %v", err) - } -} + It("rejects a port file with a loose mode", func() { + root := GinkgoT().TempDir() + path := filepath.Join(root, loopbackPortFile) + Expect(os.WriteFile(path, []byte("40123\n"), 0o600)).To(Succeed()) + Expect(os.Chmod(path, 0o644)).To(Succeed()) + _, _, err := NewLoopbackCarrierConfig("local", root) + Expect(err).To(HaveOccurred()) + Expect(strings.Contains(err.Error(), "0600")).To(BeTrue(), "expected a mode error, got %v", err) + }) -func TestLoopbackRejectsPortFileSymlinkOutsideRoot(t *testing.T) { - root := t.TempDir() - outside := filepath.Join(t.TempDir(), "port") - if err := os.WriteFile(outside, []byte("40123\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(root, loopbackPortFile)); err != nil { - t.Fatal(err) - } - if _, _, err := NewLoopbackCarrierConfig("local", root); err == nil { - t.Fatal("expected an error for a port file that links outside the root") - } -} + It("rejects a port file that links outside the root", func() { + root := GinkgoT().TempDir() + outside := filepath.Join(GinkgoT().TempDir(), "port") + Expect(os.WriteFile(outside, []byte("40123\n"), 0o600)).To(Succeed()) + Expect(os.Symlink(outside, filepath.Join(root, loopbackPortFile))).To(Succeed()) + _, _, err := NewLoopbackCarrierConfig("local", root) + Expect(err).To(HaveOccurred(), "expected an error for a port file that links outside the root") + }) +}) diff --git a/core/services/addons/toolargs/toolargs_suite_test.go b/core/services/addons/toolargs/toolargs_suite_test.go new file mode 100644 index 000000000..8f248aaa6 --- /dev/null +++ b/core/services/addons/toolargs/toolargs_suite_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +package toolargs + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestToolargs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tool arguments suite") +} diff --git a/core/services/addons/toolargs/toolargs_test.go b/core/services/addons/toolargs/toolargs_test.go index 1292d4487..8327c8232 100644 --- a/core/services/addons/toolargs/toolargs_test.go +++ b/core/services/addons/toolargs/toolargs_test.go @@ -3,9 +3,10 @@ package toolargs import ( "encoding/json" - "errors" "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // args builds an argument object of exactly n bytes with one string property. @@ -15,21 +16,18 @@ func args(n int) json.RawMessage { return json.RawMessage(`{"content":"` + strings.Repeat("a", n-len(frame)) + `"}`) } -func TestTheArgumentLimitIsOneMiBAndOneByteMoreIsRefused(t *testing.T) { - if MaxArgumentBytes != 1<<20 { - t.Fatalf("limit %d, want 1 MiB", MaxArgumentBytes) - } - params := map[string]any{"properties": map[string]any{"content": map[string]any{"type": "string"}}} - // The limit is on what the add-on receives: the tool key counts. - const toolKey = len(`,"tool":"t"`) - _, body, err := Bind("t", params, args(MaxArgumentBytes-toolKey), false) - if err != nil || len(body) != MaxArgumentBytes { - t.Fatalf("a body of exactly 1 MiB must be accepted: %v (%d bytes)", err, len(body)) - } - if _, _, err := Bind("t", params, args(MaxArgumentBytes-toolKey+1), false); !errors.Is(err, ErrInvalid) { - t.Fatalf("a body of 1 MiB + 1 byte must be refused, got %v", err) - } - if _, _, err := Bind("t", params, args(MaxArgumentBytes+1), false); !errors.Is(err, ErrInvalid) { - t.Fatalf("an input over 1 MiB must be refused, got %v", err) - } -} +var _ = Describe("the argument limit", func() { + It("is one MiB and one byte more is refused", func() { + Expect(MaxArgumentBytes).To(Equal(1<<20), "limit must be 1 MiB") + params := map[string]any{"properties": map[string]any{"content": map[string]any{"type": "string"}}} + // The limit is on what the add-on receives: the tool key counts. + const toolKey = len(`,"tool":"t"`) + _, body, err := Bind("t", params, args(MaxArgumentBytes-toolKey), false) + Expect(err).ToNot(HaveOccurred(), "a body of exactly 1 MiB must be accepted") + Expect(body).To(HaveLen(MaxArgumentBytes)) + _, _, err = Bind("t", params, args(MaxArgumentBytes-toolKey+1), false) + Expect(err).To(MatchError(ErrInvalid), "a body of 1 MiB + 1 byte must be refused") + _, _, err = Bind("t", params, args(MaxArgumentBytes+1), false) + Expect(err).To(MatchError(ErrInvalid), "an input over 1 MiB must be refused") + }) +}) diff --git a/pkg/addons/contract/hostops_test.go b/pkg/addons/contract/hostops_test.go index 9b3c336d4..948c8771c 100644 --- a/pkg/addons/contract/hostops_test.go +++ b/pkg/addons/contract/hostops_test.go @@ -4,57 +4,68 @@ package contract import ( "encoding/json" "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestHostWireTypesRoundTrip(t *testing.T) { - req := HostRequest{OperationID: "op1", Op: "finetune.get", Arguments: json.RawMessage(`{"jobId":"j1"}`)} - b, err := json.Marshal(req) - if err != nil { - t.Fatal(err) - } - got, err := DecodeHostRequest(b, 1<<20) - if err != nil || got.OperationID != "op1" || got.Op != "finetune.get" || string(got.Arguments) != `{"jobId":"j1"}` { - t.Fatalf("%+v %v", got, err) - } - resp := HostResponse{Error: &HostError{Code: "scope", Message: "no"}} - b, _ = json.Marshal(resp) - var back HostResponse - if err := json.Unmarshal(b, &back); err != nil || back.Error == nil || back.Error.Code != "scope" || back.Error.Error() == "" { - t.Fatalf("%+v %v", back, err) - } - b, _ = json.Marshal(HostResponse{Result: json.RawMessage(`{"ok":true}`)}) - if err := json.Unmarshal(b, &back); err != nil || string(back.Result) != `{"ok":true}` { - t.Fatalf("%s %v", b, err) - } -} +var _ = Describe("host operation wire types", func() { + It("round-trip", func() { + req := HostRequest{OperationID: "op1", Op: "finetune.get", Arguments: json.RawMessage(`{"jobId":"j1"}`)} + b, err := json.Marshal(req) + Expect(err).ToNot(HaveOccurred()) + got, err := DecodeHostRequest(b, 1<<20) + Expect(err).ToNot(HaveOccurred()) + Expect(got.OperationID).To(Equal("op1")) + Expect(got.Op).To(Equal("finetune.get")) + Expect(string(got.Arguments)).To(Equal(`{"jobId":"j1"}`)) -func TestTheStrictDecoderRefusesWhatItDoesNotKnow(t *testing.T) { - for name, body := range map[string]string{ - "unknown field": `{"operationId":"a","op":"finetune.get","arguments":{},"userId":"x"}`, - "duplicate key": `{"operationId":"a","operationId":"b","op":"finetune.get"}`, - "trailing": `{"operationId":"a","op":"finetune.get"} {}`, - "not an object": `[]`, - "empty": ``, - } { - if _, err := DecodeHostRequest([]byte(body), 1<<20); err == nil { - t.Errorf("%s: accepted", name) - } - } - if _, err := DecodeHostRequest([]byte(`{"operationId":"a","op":"finetune.get"}`), 10); err == nil { - t.Error("an oversized body was accepted") - } -} + resp := HostResponse{Error: &HostError{Code: "scope", Message: "no"}} + b, _ = json.Marshal(resp) + var back HostResponse + Expect(json.Unmarshal(b, &back)).To(Succeed()) + Expect(back.Error).ToNot(BeNil()) + Expect(back.Error.Code).To(Equal("scope")) + Expect(back.Error.Error()).ToNot(BeEmpty()) -func TestAnOperationNameHasTheDottedShape(t *testing.T) { - for op, want := range map[string]bool{ - "finetune.get": true, "quantize.start": true, "finetune.a.b": true, - "": false, "finetune": false, ".get": false, "Finetune.get": false, "finetune.get ": false, - "finetune/get": false, "finetune.get\n": false, "fine-tune.get": false, "finetune.": false, - strings.Repeat("a", 70) + ".get": false, - } { - if got := ValidHostOp(op); got != want { - t.Errorf("%q: got %v want %v", op, got, want) - } - } -} + b, _ = json.Marshal(HostResponse{Result: json.RawMessage(`{"ok":true}`)}) + Expect(json.Unmarshal(b, &back)).To(Succeed()) + Expect(string(back.Result)).To(Equal(`{"ok":true}`)) + }) + + DescribeTable("the strict decoder refuses what it does not know", + func(body string) { + _, err := DecodeHostRequest([]byte(body), 1<<20) + Expect(err).To(HaveOccurred(), "accepted") + }, + Entry("unknown field", `{"operationId":"a","op":"finetune.get","arguments":{},"userId":"x"}`), + Entry("duplicate key", `{"operationId":"a","operationId":"b","op":"finetune.get"}`), + Entry("trailing", `{"operationId":"a","op":"finetune.get"} {}`), + Entry("not an object", `[]`), + Entry("empty", ``), + ) + + It("refuses an oversized body", func() { + _, err := DecodeHostRequest([]byte(`{"operationId":"a","op":"finetune.get"}`), 10) + Expect(err).To(HaveOccurred(), "an oversized body was accepted") + }) + + DescribeTable("an operation name has the dotted shape", + func(op string, want bool) { + Expect(ValidHostOp(op)).To(Equal(want), "%q", op) + }, + Entry("finetune.get", "finetune.get", true), + Entry("quantize.start", "quantize.start", true), + Entry("finetune.a.b", "finetune.a.b", true), + Entry("empty", "", false), + Entry("no dot", "finetune", false), + Entry("leading dot", ".get", false), + Entry("uppercase", "Finetune.get", false), + Entry("trailing space", "finetune.get ", false), + Entry("slash", "finetune/get", false), + Entry("trailing newline", "finetune.get\n", false), + Entry("hyphen", "fine-tune.get", false), + Entry("empty verb", "finetune.", false), + Entry("too long", strings.Repeat("a", 70)+".get", false), + ) +}) diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go index 416a59e01..fc3d6a971 100644 --- a/pkg/addons/packaging/packaging_test.go +++ b/pkg/addons/packaging/packaging_test.go @@ -12,7 +12,6 @@ import ( "os/exec" "path/filepath" "strings" - "testing" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -1091,86 +1090,82 @@ var _ = Describe("workflow policy", func() { // written out so a new add-on is a deliberate change here and in the workflow. var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk"} -func readWorkflow(t *testing.T) string { - t.Helper() +func readWorkflow() string { + GinkgoHelper() b, err := os.ReadFile("../../../.github/workflows/addons-package.yml") - if err != nil { - t.Fatal(err) - } + Expect(err).ToNot(HaveOccurred()) return string(b) } -func TestTheWorkflowHasNoHardCodedAddonPath(t *testing.T) { - text := readWorkflow(t) - for i, line := range strings.Split(text, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { - continue +var _ = Describe("the add-on package workflow", func() { + It("has no hard-coded add-on path", func() { + text := readWorkflow() + for i, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + Expect(line).ToNot(ContainSubstring("addons/tables/"), "line %d names addons/tables/ outside the add-on list: %s", i+1, trimmed) } - if strings.Contains(line, "addons/tables/") { - t.Errorf("line %d names addons/tables/ outside the add-on list: %s", i+1, trimmed) - } - } -} + }) -func TestTheWorkflowRunsEveryFirstPartyAddon(t *testing.T) { - var doc struct { - Jobs map[string]struct { - Strategy struct { - Matrix any `yaml:"matrix"` - } `yaml:"strategy"` - Steps []map[string]any `yaml:"steps"` - } `yaml:"jobs"` - } - if err := yaml.Unmarshal([]byte(readWorkflow(t)), &doc); err != nil { - t.Fatal(err) - } - // validate lists the add-ons literally. The other jobs take the add-ons - // that have an enabled platform from the plan. - list := func(v any) []string { - var out []string - items, _ := v.([]any) - for _, x := range items { - out = append(out, fmt.Sprint(x)) + Context("for every first-party add-on", func() { + var doc struct { + Jobs map[string]struct { + Strategy struct { + Matrix any `yaml:"matrix"` + } `yaml:"strategy"` + Steps []map[string]any `yaml:"steps"` + } `yaml:"jobs"` } - return out - } - matrixOf := func(job string) map[string]any { m, _ := doc.Jobs[job].Strategy.Matrix.(map[string]any); return m } - got := list(matrixOf("validate")["addon"]) - if strings.Join(got, ",") != strings.Join(firstPartyAddons, ",") { - t.Fatalf("validate matrix lists %v, want %v", got, firstPartyAddons) - } - for _, job := range []string{"publish", "promote"} { - if _, ok := matrixOf(job)["addon"]; !ok { - t.Errorf("%s has no addon matrix", job) + matrixOf := func(job string) map[string]any { m, _ := doc.Jobs[job].Strategy.Matrix.(map[string]any); return m } + + BeforeEach(func() { + Expect(yaml.Unmarshal([]byte(readWorkflow()), &doc)).To(Succeed()) + }) + + It("lists the add-ons in the validate matrix", func() { + // validate lists the add-ons literally. The other jobs take the add-ons + // that have an enabled platform from the plan. + var got []string + items, _ := matrixOf("validate")["addon"].([]any) + for _, x := range items { + got = append(got, fmt.Sprint(x)) + } + Expect(strings.Join(got, ",")).To(Equal(strings.Join(firstPartyAddons, ",")), "validate matrix lists %v, want %v", got, firstPartyAddons) + }) + + for _, job := range []string{"publish", "promote"} { + It("gives "+job+" an add-on matrix", func() { + Expect(matrixOf(job)).To(HaveKey("addon"), "%s has no addon matrix", job) + }) } - } - if !strings.Contains(fmt.Sprint(doc.Jobs["package"].Strategy.Matrix), "needs.plan.outputs.matrix") { - t.Error("package does not take its legs from the plan") - } - // The plan loops over the same list. - plan := readWorkflow(t) - if !strings.Contains(plan, "for id in "+strings.Join(firstPartyAddons, " ")+"; do") { - t.Errorf("plan does not loop over exactly %v", firstPartyAddons) - } - for _, id := range firstPartyAddons { - file := "../../../addons/" + id + "/package/platforms.yaml" - cfg, err := loadPlatforms(file) - if err != nil { - t.Errorf("%s: %v", id, err) - continue + + It("takes the package legs from the plan", func() { + Expect(fmt.Sprint(doc.Jobs["package"].Strategy.Matrix)).To(ContainSubstring("needs.plan.outputs.matrix"), + "package does not take its legs from the plan") + }) + + It("loops the plan over the same list", func() { + plan := readWorkflow() + Expect(plan).To(ContainSubstring("for id in "+strings.Join(firstPartyAddons, " ")+"; do"), + "plan does not loop over exactly %v", firstPartyAddons) + }) + + for _, id := range firstPartyAddons { + It("has consistent package files for "+id, func() { + file := "../../../addons/" + id + "/package/platforms.yaml" + cfg, err := loadPlatforms(file) + Expect(err).ToNot(HaveOccurred(), id) + Expect(cfg.Addon).To(Equal(id), "platforms file names add-on %q", cfg.Addon) + Expect(cfg.Manifest).To(Equal("addons/"+id+"/manifest.json"), "platforms file names manifest %q", cfg.Manifest) + Expect(cfg.UI).To(Equal("addons/"+id+"/ui/dist"), "ui is %q", cfg.UI) + _, err = os.Stat("../../../addons/" + id + "/cmd/" + cfg.Executable + "/main.go") + Expect(err).ToNot(HaveOccurred(), "no executable source for %q", cfg.Executable) + }) } - if cfg.Addon != id || cfg.Manifest != "addons/"+id+"/manifest.json" { - t.Errorf("%s: platforms file names add-on %q and manifest %q", id, cfg.Addon, cfg.Manifest) - } - if cfg.UI != "addons/"+id+"/ui/dist" { - t.Errorf("%s: ui is %q", id, cfg.UI) - } - if _, err := os.Stat("../../../addons/" + id + "/cmd/" + cfg.Executable + "/main.go"); err != nil { - t.Errorf("%s: no executable source for %q: %v", id, cfg.Executable, err) - } - } -} + }) +}) // documentedAddonRepository is the value the documentation gives for the // ADDON_OCI_REPOSITORY variable: the Tables repository in the add-on namespace. diff --git a/pkg/addons/packaging/platforms_test.go b/pkg/addons/packaging/platforms_test.go index d6c2e2797..bffec66ca 100644 --- a/pkg/addons/packaging/platforms_test.go +++ b/pkg/addons/packaging/platforms_test.go @@ -6,7 +6,9 @@ import ( "path/filepath" "sort" "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) // The release platforms of the packaged first-party add-ons, with the runner @@ -26,72 +28,50 @@ var nativeChecked = map[string]bool{"linux/amd64": true, "linux/arm64": true, "d var packagedAddons = []string{"voice", "face", "fine-tuning", "quantization", "studio", "talk"} -func TestEveryPackagedAddonListsEveryReleasePlatformWithItsRunner(t *testing.T) { +var _ = Describe("the packaged add-ons", func() { for _, id := range packagedAddons { - p, err := loadPlatforms(filepath.Join("..", "..", "..", "addons", id, "package", "platforms.yaml")) - if err != nil { - t.Fatalf("%s: %v", id, err) - } - if p.Addon != id || p.Executable != id { - t.Fatalf("%s: addon %q executable %q", id, p.Addon, p.Executable) - } - got := map[string]string{} - for _, e := range p.Platforms { - got[e.Platform] = e.Runner - if e.NativeCheck != NativeCheckKind { - t.Fatalf("%s %s: native check %q", id, e.Platform, e.NativeCheck) - } - } - if len(got) != len(releasePlatforms) { - t.Fatalf("%s lists %d platforms, want %d", id, len(got), len(releasePlatforms)) - } - for platform, runner := range releasePlatforms { - if got[platform] != runner { - t.Fatalf("%s: platform %s has runner %q, want %q", id, platform, got[platform], runner) - } - } - } -} + Context(id, func() { + It("lists every release platform with its runner", func() { + p, err := loadPlatforms(filepath.Join("..", "..", "..", "addons", id, "package", "platforms.yaml")) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Addon).To(Equal(id)) + Expect(p.Executable).To(Equal(id)) + got := map[string]string{} + for _, e := range p.Platforms { + got[e.Platform] = e.Runner + Expect(e.NativeCheck).To(Equal(NativeCheckKind), "%s: native check", e.Platform) + } + Expect(got).To(HaveLen(len(releasePlatforms))) + for platform, runner := range releasePlatforms { + Expect(got[platform]).To(Equal(runner), "platform %s", platform) + } + }) -func TestAPlatformIsEnabledExactlyWhenItsNativeCheckIsRecorded(t *testing.T) { - for _, id := range packagedAddons { - p, err := loadPlatforms(filepath.Join("..", "..", "..", "addons", id, "package", "platforms.yaml")) - if err != nil { - t.Fatal(err) - } - var enabled []string - for _, e := range p.Platforms { - if e.Enabled { - enabled = append(enabled, e.Platform) - } - } - var recorded []string - for platform := range nativeChecked { - recorded = append(recorded, platform) - } - sort.Strings(enabled) - sort.Strings(recorded) - if len(enabled) != len(recorded) { - t.Fatalf("%s: enabled %v, native check recorded for %v", id, enabled, recorded) - } - for i := range enabled { - if enabled[i] != recorded[i] { - t.Fatalf("%s: enabled %v, native check recorded for %v", id, enabled, recorded) - } - } - } -} + It("enables a platform exactly when its native check is recorded", func() { + p, err := loadPlatforms(filepath.Join("..", "..", "..", "addons", id, "package", "platforms.yaml")) + Expect(err).ToNot(HaveOccurred()) + var enabled []string + for _, e := range p.Platforms { + if e.Enabled { + enabled = append(enabled, e.Platform) + } + } + var recorded []string + for platform := range nativeChecked { + recorded = append(recorded, platform) + } + sort.Strings(enabled) + sort.Strings(recorded) + Expect(enabled).To(Equal(recorded), "enabled platforms and recorded native checks differ") + }) -func TestEveryPackagedAddonHasACatalogTemplate(t *testing.T) { - for _, id := range packagedAddons { - b, err := os.ReadFile(filepath.Join("..", "..", "..", "gallery", "addons", id+".yaml.tmpl")) - if err != nil { - t.Fatalf("%s: %v", id, err) - } - for _, want := range []string{`addonId: "{{addon}}"`, "requireSignature: true"} { - if !strings.Contains(string(b), want) { - t.Fatalf("%s template lacks %q", id, want) - } - } + It("has a catalog template", func() { + b, err := os.ReadFile(filepath.Join("..", "..", "..", "gallery", "addons", id+".yaml.tmpl")) + Expect(err).ToNot(HaveOccurred()) + for _, want := range []string{`addonId: "{{addon}}"`, "requireSignature: true"} { + Expect(strings.Contains(string(b), want)).To(BeTrue(), "template lacks %q", want) + } + }) + }) } -} +})