diff --git a/.codescene/code-health-rules.json b/.codescene/code-health-rules.json new file mode 100644 index 0000000..22502de --- /dev/null +++ b/.codescene/code-health-rules.json @@ -0,0 +1,11 @@ +{ + "rule_sets": [ + { + "matching_content_path": "**/*_test.go", + "rules": [ + { "name": "String Heavy Function Arguments", "weight": 0.0 }, + { "name": "Primitive Obsession", "weight": 0.0 } + ] + } + ] +} diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 24e3bfa..de441f9 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -3,6 +3,12 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to 0.5.1 were tagged `vX.Y.Z` in `github.com/gopherium/pluginkit`. +## Unreleased + +- `RegisterEach` calls each `Registration` with the dependencies `depsFor` builds for its id, each inside its own recover, goes on past an error or a panic, and answers a `RegisterOutcome` naming each failure at the new `StepRegister`. +- `wire.Config` gains an optional `RegistrationList`, which makes each generated Go file hold a list of every backend plugin's id and `Register`, `pluginRegistrations` in the main file and `Registrations` in the registry, in place of `registerPlugins` and `All`. +- The host's own recovers name a panic whose value panics again while it is printed by its type, where the process used to crash. + ## 0.11.0 - 2026-11-07 - `wire.Manifest` reads `menu` as `[]MenuEntry`, and `screens` and `screen_capabilities`, which no longer sit in `Extra`, a breaking change. diff --git a/pluginkit/features/features_test.go b/pluginkit/features/features_test.go index 3ffc567..15a8a55 100644 --- a/pluginkit/features/features_test.go +++ b/pluginkit/features/features_test.go @@ -41,3 +41,7 @@ func TestHooks(t *testing.T) { func TestScreens(t *testing.T) { runFeature(t, "screens.feature", initializeScreens(t)) } + +func TestRegistration(t *testing.T) { + runFeature(t, "registration.feature", initializeRegistration(t)) +} diff --git a/pluginkit/features/registration.feature b/pluginkit/features/registration.feature new file mode 100644 index 0000000..13e9771 --- /dev/null +++ b/pluginkit/features/registration.feature @@ -0,0 +1,92 @@ +Feature: Registering compiled plugins one by one + A downstream application can ask the wiring generator for a list of + every backend plugin's id and Register function, in wiring order, in + place of one function that registers them all. The application keeps + the plugins it wants on, builds each one's dependencies from its id, + and registers them one by one. A plugin whose Register fails or panics + is reported by its id, and the plugins after it still register. + + Scenario: The registration list names each backend plugin in wiring order + Given the plugin "events" has a backend + And the plugin "gallery" has a backend + And the plugin "pretty" has only the frontend "@example/plugin-pretty" + And the application asks for a registration list + When the plugins are wired + Then the registration list names "events" and "gallery", in that order + And the Go wiring holds no registerPlugins function + + Scenario: The registry holds an exported registration list in place of All + Given the plugin "events" has a backend + And the application asks for a registration list and a registry + When the plugins are wired + Then the registry's Registrations names "events" + And the registry holds no All function + + Scenario: Without a registration list the Go wiring keeps registerPlugins + Given the plugin "events" has a backend + When the plugins are wired + Then the Go wiring holds the registerPlugins function + And the Go wiring holds no registration list + + Scenario: Only the plugins the application passes are registered + Given the compiled plugins "events", "feed" and "gallery" + And the application leaves "gallery" out + When the application registers each plugin + Then "events" and "feed" are registered, in that order + And the Register of "gallery" never ran + + Scenario: Each plugin receives the dependencies built for its own id + Given the compiled plugins "events" and "feed" + When the application registers each plugin + Then the Register of "events" received the dependencies built for "events" + And the Register of "feed" received the dependencies built for "feed" + And dependencies were built for "events" and "feed", in that order, once each + + Scenario: A plugin whose Register panics fails alone + Given the compiled plugins "events", "feed" and "gallery" + And the Register of "feed" panics with "boom" + When the application registers each plugin + Then "events" and "gallery" are registered, in that order + And "feed" failed at the step "register" with: pluginkit: feed register panicked: boom + And the failure of "feed" is marked as a panic + + Scenario: A plugin whose Register answers an error fails alone + Given the compiled plugins "events", "feed" and "gallery" + And the Register of "feed" answers the error "the title is too long" + When the application registers each plugin + Then "events" and "gallery" are registered, in that order + And "feed" failed at the step "register" with: pluginkit: feed register: the title is too long + And the failure of "feed" is not marked as a panic + + Scenario: An error that holds the text of a panic is not marked as a panic + Given the compiled plugins "events" and "feed" + And the Register of "feed" answers the error "pluginkit: feed register panicked: boom" + When the application registers each plugin + Then "feed" failed at the step "register" with: pluginkit: feed register: pluginkit: feed register panicked: boom + And the failure of "feed" is not marked as a panic + + Scenario: Registering no plugin answers an empty outcome + Given no compiled plugin + When the application registers each plugin + Then no plugin is registered and none failed + + Scenario: A panic while the application builds dependencies is not recovered + Given the compiled plugins "events" and "feed" + And building the dependencies of "feed" panics with "no settings for feed" + When the application registers each plugin + Then the registering panics with "no settings for feed" + And the Register of "feed" never ran + + Scenario Outline: The generated list builds and registers each plugin + Given an SDK whose Plugin is + And the plugin "events" has a backend whose Register answers a plugin + And the plugin "feed" has a backend whose Register answers the error "no database" + And the application asks for a registration list + When the plugins are wired, built and registered one by one + Then "events" is registered + And "feed" failed at the step "register" with: pluginkit: feed register: no database + + Examples: + | sdk plugin | + | an alias of the pluginkit Plugin | + | an interface of its own | diff --git a/pluginkit/features/steps_registration_test.go b/pluginkit/features/steps_registration_test.go new file mode 100644 index 0000000..625c54f --- /dev/null +++ b/pluginkit/features/steps_registration_test.go @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: Apache-2.0 + +package features_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "github.com/cucumber/godog" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/wire" +) + +// registrationConfig is the wiring a registration scenario starts from. +var registrationConfig = wire.Config{ + SDKImport: "example.com/app/sdk", + FrontendSDK: "@example/frontend-sdk", + GoWiringPath: "cmd/app/plugins_gen.go", + TSWiringPath: "web/plugins.ts", + License: "Apache-2.0", +} + +// listedID finds the id of each entry of a generated registration list. +var listedID = regexp.MustCompile(`\{ID: ("(?:[^"\\]|\\.)*"), Register:`) + +// laneDeps is what a scenario lends a plugin when it registers. +type laneDeps struct{ id string } + +// lanePlugin is a plugin a scenario registered. +type lanePlugin struct{ id string } + +// registrationFailure is one failure of a registration outcome, its error as text. +type registrationFailure struct { + ID string + Step string + Panicked bool + Err string +} + +// registrationWorld is what one scenario of the registration feature builds and finds. +type registrationWorld struct { + t *testing.T + root string + manifests map[string]map[string]any + cfg wire.Config + err error + compiled []string + leftOut map[string]bool + answers map[string]func() error + depsPanic map[string]string + built []string + received map[string]laneDeps + registered []string + failed []registrationFailure + escaped any + ownPlugin bool + sources map[string]string +} + +// backend gives the plugin id a manifest with a backend. +func (w *registrationWorld) backend(id string) { + w.manifests[id] = map[string]any{"id": id, "name": id, "backend": "example.com/app/plugins/" + id} +} + +// onlyFrontend gives the plugin id a manifest with only a frontend. +func (w *registrationWorld) onlyFrontend(id, frontend string) { + w.manifests[id] = map[string]any{"id": id, "name": id, "frontend": frontend} +} + +// asksForList switches the registration list on. +func (w *registrationWorld) asksForList() { + w.cfg.RegistrationList = true +} + +// asksForListAndRegistry switches the registration list and the registry on. +func (w *registrationWorld) asksForListAndRegistry() { + w.cfg.RegistrationList = true + w.cfg.GoRegistryPath = "registry/registry_gen.go" + w.cfg.GoRegistryPackage = "registry" +} + +// writeFile writes text to the slash path under the scenario's root. +func (w *registrationWorld) writeFile(path, text string) error { + full := filepath.Join(w.root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err + } + return os.WriteFile(full, []byte(text), 0o644) +} + +// readFile returns the slash path under the scenario's root. +func (w *registrationWorld) readFile(path string) (string, error) { + data, err := os.ReadFile(filepath.Join(w.root, filepath.FromSlash(path))) + return string(data), err +} + +// addManifests adds every manifest of the scenario to its source files. +func (w *registrationWorld) addManifests() error { + for id, m := range w.manifests { + data, err := json.Marshal(m) + if err != nil { + return err + } + w.sources["plugins/"+id+"/plugin.json"] = string(data) + } + return nil +} + +// makeWiringFolders creates the folder of every file the wiring writes. +func (w *registrationWorld) makeWiringFolders() error { + var err error + for _, path := range []string{w.cfg.GoWiringPath, w.cfg.TSWiringPath, w.cfg.GoRegistryPath} { + err = errors.Join(err, os.MkdirAll(filepath.Join(w.root, filepath.Dir(filepath.FromSlash(path))), 0o755)) + } + return err +} + +// writeTree writes every manifest and source file of the scenario under a new root, and the folders the wiring needs. +func (w *registrationWorld) writeTree() error { + w.root = w.t.TempDir() + if err := w.addManifests(); err != nil { + return err + } + var err error + for path, text := range w.sources { + err = errors.Join(err, w.writeFile(path, text)) + } + return errors.Join(err, w.makeWiringFolders()) +} + +// wirePlugins runs the generator over the manifests the scenario wrote. +func (w *registrationWorld) wirePlugins() error { + if err := w.writeTree(); err != nil { + return err + } + w.err = wire.Run(w.root, w.cfg) + return nil +} + +// wiredFile returns the generated file at path, failing when the run failed. +func (w *registrationWorld) wiredFile(path string) (string, error) { + if w.err != nil { + return "", fmt.Errorf("the run failed: %w", w.err) + } + return w.readFile(path) +} + +// listedIDs returns the ids the registration list in the file at path names, in order. +func (w *registrationWorld) listedIDs(path string) ([]string, error) { + text, err := w.wiredFile(path) + if err != nil { + return nil, err + } + ids := []string{} + for _, match := range listedID.FindAllStringSubmatch(text, -1) { + var id string + if err := json.Unmarshal([]byte(match[1]), &id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +// listNames checks that the main registration list names the plugins of list, in order. +func (w *registrationWorld) listNames(list string) error { + ids, err := w.listedIDs(w.cfg.GoWiringPath) + if err != nil { + return err + } + if want := names(list); !slices.Equal(ids, want) { + return fmt.Errorf("the registration list names %q, want %q", ids, want) + } + return nil +} + +// registryNames checks that the registry's list names only the plugin id. +func (w *registrationWorld) registryNames(id string) error { + ids, err := w.listedIDs(w.cfg.GoRegistryPath) + if err != nil { + return err + } + if !slices.Equal(ids, []string{id}) { + return fmt.Errorf("the registry's list names %q, want only %q", ids, id) + } + return nil +} + +// holds checks whether the generated file at path holds the text part, as wanted. +func (w *registrationWorld) holds(path, part string, want bool) error { + text, err := w.wiredFile(path) + if err != nil { + return err + } + if strings.Contains(text, part) != want { + return fmt.Errorf("%s holding %q is %t, want %t", path, part, !want, want) + } + return nil +} + +// noRegisterPlugins checks that the Go wiring declares no registerPlugins function. +func (w *registrationWorld) noRegisterPlugins() error { + return w.holds(w.cfg.GoWiringPath, "func registerPlugins(", false) +} + +// keepsRegisterPlugins checks that the Go wiring declares the registerPlugins function. +func (w *registrationWorld) keepsRegisterPlugins() error { + return w.holds(w.cfg.GoWiringPath, "func registerPlugins(", true) +} + +// noList checks that the Go wiring holds no registration list. +func (w *registrationWorld) noList() error { + return w.holds(w.cfg.GoWiringPath, "pluginRegistrations", false) +} + +// noAll checks that the registry declares no All function. +func (w *registrationWorld) noAll() error { + return w.holds(w.cfg.GoRegistryPath, "func All(", false) +} + +// compile lists the plugins of list as compiled, each registering unless told otherwise. +func (w *registrationWorld) compile(list string) { + w.compiled = names(list) +} + +// noneCompiled lists no compiled plugin. +func (w *registrationWorld) noneCompiled() { + w.compiled = []string{} +} + +// leaveOut keeps the plugin id out of what the application registers. +func (w *registrationWorld) leaveOut(id string) { + w.leftOut[id] = true +} + +// registerPanics makes the Register of id panic with value. +func (w *registrationWorld) registerPanics(id, value string) { + w.answers[id] = func() error { panic(value) } +} + +// registerFails makes the Register of id answer an error with text. +func (w *registrationWorld) registerFails(id, text string) { + w.answers[id] = func() error { return errors.New(text) } +} + +// depsPanics makes building the dependencies of id panic with value. +func (w *registrationWorld) depsPanics(id, value string) { + w.depsPanic[id] = value +} + +// entries returns the registration of each compiled plugin the application keeps, in order. +func (w *registrationWorld) entries() []pluginkit.Registration[laneDeps, *lanePlugin] { + var kept []pluginkit.Registration[laneDeps, *lanePlugin] + for _, id := range w.compiled { + if w.leftOut[id] { + continue + } + kept = append(kept, pluginkit.Registration[laneDeps, *lanePlugin]{ID: id, Register: w.registerOf(id)}) + } + return kept +} + +// registerOf returns the Register of the plugin id, noting the deps it receives. +func (w *registrationWorld) registerOf(id string) func(laneDeps) (*lanePlugin, error) { + return func(deps laneDeps) (*lanePlugin, error) { + w.received[id] = deps + if answer, ok := w.answers[id]; ok { + if err := answer(); err != nil { + return nil, err + } + } + return &lanePlugin{id: id}, nil + } +} + +// depsFor builds the deps of id, noting the call, and panics when the scenario says so. +func (w *registrationWorld) depsFor(id string) laneDeps { + w.built = append(w.built, id) + if value, ok := w.depsPanic[id]; ok { + panic(value) + } + return laneDeps{id: id} +} + +// registerEach registers the plugins the application keeps, noting the outcome or the panic that escaped. +func (w *registrationWorld) registerEach() { + defer func() { w.escaped = recover() }() + outcome := pluginkit.RegisterEach(w.entries(), w.depsFor) + for _, plugin := range outcome.Registered { + w.registered = append(w.registered, plugin.id) + } + for _, f := range outcome.Failed { + w.failed = append(w.failed, registrationFailure{f.ID, string(f.Step), f.Panicked, f.Err.Error()}) + } +} + +// settled returns the panic that escaped the registering, if one did. +func (w *registrationWorld) settled() error { + if w.escaped != nil { + return fmt.Errorf("the registering panicked: %v", w.escaped) + } + return nil +} + +// areRegistered checks that exactly the plugins of list registered, in order. +func (w *registrationWorld) areRegistered(list string) error { + if err := w.settled(); err != nil { + return err + } + if want := names(list); !slices.Equal(w.registered, want) { + return fmt.Errorf("registered %q, want %q", w.registered, want) + } + return nil +} + +// neverRan checks that the Register of id was not called. +func (w *registrationWorld) neverRan(id string) error { + if _, ran := w.received[id]; ran { + return fmt.Errorf("the Register of %s ran", id) + } + return nil +} + +// receivedDeps checks that the Register of id received the deps built for owner. +func (w *registrationWorld) receivedDeps(id, owner string) error { + if got := w.received[id]; got != (laneDeps{id: owner}) { + return fmt.Errorf("the Register of %s received %+v, want the deps of %s", id, got, owner) + } + return nil +} + +// builtOnce checks that deps were built for the plugins of list, in order, once each. +func (w *registrationWorld) builtOnce(list string) error { + if want := names(list); !slices.Equal(w.built, want) { + return fmt.Errorf("deps were built for %q, want %q", w.built, want) + } + return nil +} + +// failure returns the failure of id. +func (w *registrationWorld) failure(id string) (registrationFailure, error) { + if err := w.settled(); err != nil { + return registrationFailure{}, err + } + i := slices.IndexFunc(w.failed, func(f registrationFailure) bool { return f.ID == id }) + if i < 0 { + return registrationFailure{}, fmt.Errorf("no failure of %s among %+v", id, w.failed) + } + return w.failed[i], nil +} + +// failedAt checks that id failed at step with the text. +func (w *registrationWorld) failedAt(id, step, text string) error { + f, err := w.failure(id) + if err != nil { + return err + } + if f.Step != step || f.Err != text { + return fmt.Errorf("%s failed at %q with %q, want %q with %q", id, f.Step, f.Err, step, text) + } + return nil +} + +// markedPanic checks whether the failure of id is marked as a panic, as the step says. +func (w *registrationWorld) markedPanic(id, not string) error { + f, err := w.failure(id) + if err != nil { + return err + } + if want := not == ""; f.Panicked != want { + return fmt.Errorf("the failure of %s is marked as a panic %t, want %t", id, f.Panicked, want) + } + return nil +} + +// noneAtAll checks that nothing registered and nothing failed. +func (w *registrationWorld) noneAtAll() error { + if err := w.settled(); err != nil { + return err + } + if len(w.registered)+len(w.failed) > 0 { + return fmt.Errorf("registered %q and failed %+v, want neither", w.registered, w.failed) + } + return nil +} + +// panicked checks that the registering panicked with value. +func (w *registrationWorld) panicked(value string) error { + if !reflect.DeepEqual(w.escaped, value) { + return fmt.Errorf("the registering panicked with %v, want %q", w.escaped, value) + } + return nil +} + +// givenRegistration registers the steps that write manifests and plugins and ask for the list. +func givenRegistration(sc *godog.ScenarioContext, w *registrationWorld) { + sc.Step(`^the plugin "([^"]*)" has a backend$`, w.backend) + sc.Step(`^the plugin "([^"]*)" has only the frontend "([^"]*)"$`, w.onlyFrontend) + sc.Step(`^the application asks for a registration list$`, w.asksForList) + sc.Step(`^the application asks for a registration list and a registry$`, w.asksForListAndRegistry) + sc.Step(`^the compiled plugins (.+)$`, w.compile) + sc.Step(`^no compiled plugin$`, w.noneCompiled) + sc.Step(`^the application leaves "([^"]*)" out$`, w.leaveOut) + sc.Step(`^the Register of "([^"]*)" panics with "([^"]*)"$`, w.registerPanics) + sc.Step(`^the Register of "([^"]*)" answers the error "([^"]*)"$`, w.registerFails) + sc.Step(`^building the dependencies of "([^"]*)" panics with "([^"]*)"$`, w.depsPanics) + givenBuild(sc, w) +} + +// whenRegistration registers the steps that wire, register, or build and run the plugins. +func whenRegistration(sc *godog.ScenarioContext, w *registrationWorld) { + sc.Step(`^the plugins are wired$`, w.wirePlugins) + sc.Step(`^the application registers each plugin$`, w.registerEach) + sc.Step(`^the plugins are wired, built and registered one by one$`, w.buildAndRun) +} + +// thenRegistration registers the steps that check the generated files and the outcome. +func thenRegistration(sc *godog.ScenarioContext, w *registrationWorld) { + sc.Step(`^the registration list names (.+), in that order$`, w.listNames) + sc.Step(`^the Go wiring holds no registerPlugins function$`, w.noRegisterPlugins) + sc.Step(`^the registry's Registrations names "([^"]*)"$`, w.registryNames) + sc.Step(`^the registry holds no All function$`, w.noAll) + sc.Step(`^the Go wiring holds the registerPlugins function$`, w.keepsRegisterPlugins) + sc.Step(`^the Go wiring holds no registration list$`, w.noList) + sc.Step(`^((?:"[^"]*"(?:, | and )?)+) (?:is|are) registered(?:, in that order)?$`, w.areRegistered) + sc.Step(`^the Register of "([^"]*)" never ran$`, w.neverRan) + sc.Step(`^the Register of "([^"]*)" received the dependencies built for "([^"]*)"$`, w.receivedDeps) + sc.Step(`^dependencies were built for (.+), in that order, once each$`, w.builtOnce) + sc.Step(`^"([^"]*)" failed at the step "([^"]*)" with: (.+)$`, w.failedAt) + sc.Step(`^the failure of "([^"]*)" is (not )?marked as a panic$`, w.markedPanic) + sc.Step(`^no plugin is registered and none failed$`, w.noneAtAll) + sc.Step(`^the registering panics with "([^"]*)"$`, w.panicked) +} + +// initializeRegistration gives each scenario of the registration feature a world of its own and its steps. +func initializeRegistration(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { + w := ®istrationWorld{ + t: t, manifests: map[string]map[string]any{}, cfg: registrationConfig, leftOut: map[string]bool{}, + answers: map[string]func() error{}, depsPanic: map[string]string{}, received: map[string]laneDeps{}, + sources: map[string]string{}, + } + givenRegistration(sc, w) + whenRegistration(sc, w) + thenRegistration(sc, w) + } +} + +// sdkWithAlias is an SDK whose Plugin is pluginkit's own. +const sdkWithAlias = `package sdk + +import "github.com/gopherium/framework/pluginkit" + +type Deps struct{ ID string } + +type Plugin = pluginkit.Plugin +` + +// sdkWithOwnPlugin is an SDK that declares its own Plugin. +const sdkWithOwnPlugin = `package sdk + +import "context" + +type Deps struct{ ID string } + +type Plugin interface { + ID() string + Start(ctx context.Context) error + Stop(ctx context.Context) error +} +` + +// workingPlugin is a plugin package whose Register answers a plugin named by the deps it received. +const workingPlugin = `package %s + +import ( + "context" + + "example.com/app/sdk" +) + +type Plugin struct{ deps sdk.Deps } + +func Register(deps sdk.Deps) (*Plugin, error) { + return &Plugin{deps: deps}, nil +} + +func (p *Plugin) ID() string { return p.deps.ID } + +func (p *Plugin) Start(context.Context) error { return nil } + +func (p *Plugin) Stop(context.Context) error { return nil } +` + +// failingPlugin is a plugin package whose Register answers an error. +const failingPlugin = `package %s + +import ( + "context" + "errors" + + "example.com/app/sdk" +) + +type Plugin struct{} + +func Register(sdk.Deps) (*Plugin, error) { + return nil, errors.New(%q) +} + +func (p *Plugin) ID() string { return %q } + +func (p *Plugin) Start(context.Context) error { return nil } + +func (p *Plugin) Stop(context.Context) error { return nil } +` + +// reportingMain registers each plugin of the generated list and prints the outcome as JSON. +const reportingMain = `package main + +import ( + "encoding/json" + "os" + + "github.com/gopherium/framework/pluginkit" + + "example.com/app/sdk" +) + +type failure struct { + ID string + Step string + Panicked bool + Err string +} + +func main() { + outcome := pluginkit.RegisterEach(pluginRegistrations, func(id string) sdk.Deps { return sdk.Deps{ID: id} }) + registered := []string{} + for _, plugin := range outcome.Registered { + registered = append(registered, plugin.ID()) + } + failed := []failure{} + for _, f := range outcome.Failed { + failed = append(failed, failure{f.ID, string(f.Step), f.Panicked, f.Err.Error()}) + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"registered": registered, "failed": failed}) +} +` + +// sdkPlugin chooses the SDK the build scenario compiles against. +func (w *registrationWorld) sdkPlugin(kind string) { + w.ownPlugin = kind == "an interface of its own" +} + +// buildWorking gives the plugin id a backend whose Register answers a plugin. +func (w *registrationWorld) buildWorking(id string) { + w.backend(id) + w.sources["plugins/"+id+"/"+id+".go"] = fmt.Sprintf(workingPlugin, id) +} + +// buildFailing gives the plugin id a backend whose Register answers an error with text. +func (w *registrationWorld) buildFailing(id, text string) { + w.backend(id) + w.sources["plugins/"+id+"/"+id+".go"] = fmt.Sprintf(failingPlugin, id, text, id) +} + +// givenBuild registers the steps that write an application module to build. +func givenBuild(sc *godog.ScenarioContext, w *registrationWorld) { + sc.Step(`^an SDK whose Plugin is (an alias of the pluginkit Plugin|an interface of its own)$`, w.sdkPlugin) + sc.Step(`^the plugin "([^"]*)" has a backend whose Register answers a plugin$`, w.buildWorking) + sc.Step(`^the plugin "([^"]*)" has a backend whose Register answers the error "([^"]*)"$`, w.buildFailing) +} + +// moduleFile returns the go.mod of the application module, its pluginkit replaced by this checkout's. +func moduleFile() (string, error) { + pluginkitDir, err := filepath.Abs("..") + if err != nil { + return "", err + } + own, err := os.ReadFile(filepath.Join(pluginkitDir, "go.mod")) + if err != nil { + return "", err + } + version := regexp.MustCompile(`(?m)^go (\S+)$`).FindSubmatch(own) + if version == nil { + return "", errors.New("pluginkit's go.mod names no Go version") + } + return fmt.Sprintf("module example.com/app\n\ngo %s\n\nrequire github.com/gopherium/framework/pluginkit v0.0.0\n\n"+ + "replace github.com/gopherium/framework/pluginkit => %s\n", version[1], pluginkitDir), nil +} + +// buildAndRun writes the application module, wires it with the list on, then builds and runs it. +func (w *registrationWorld) buildAndRun() error { + mod, err := moduleFile() + if err != nil { + return err + } + w.sources["go.mod"] = mod + w.sources["sdk/sdk.go"] = sdkWithAlias + if w.ownPlugin { + w.sources["sdk/sdk.go"] = sdkWithOwnPlugin + } + w.sources["cmd/app/main.go"] = reportingMain + if err := w.wirePlugins(); err != nil || w.err != nil { + return errors.Join(err, w.err) + } + return w.run() +} + +// run builds and runs the application, reading the outcome it prints. +func (w *registrationWorld) run() error { + cmd := exec.Command("go", "run", "./cmd/app") + cmd.Dir = w.root + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod", "GOPROXY=off", "GOTOOLCHAIN=local") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("go run: %w: %s", err, stderr.String()) + } + var outcome struct { + Registered []string + Failed []registrationFailure + } + if err := json.Unmarshal(out, &outcome); err != nil { + return err + } + w.registered, w.failed = outcome.Registered, outcome.Failed + return nil +} diff --git a/pluginkit/host.go b/pluginkit/host.go index 954c6ba..541d481 100644 --- a/pluginkit/host.go +++ b/pluginkit/host.go @@ -228,7 +228,7 @@ func safeCall(ctx context.Context, id, operation string, fn func(context.Context func guarded(ctx context.Context, name string, fn func(context.Context) error) (err error) { defer func() { if recovered := recover(); recovered != nil { - err = panicError(fmt.Sprintf("pluginkit: %s panicked: %v", name, recovered)) + err = panicError("pluginkit: " + name + " panicked: " + describe(recovered)) } }() if err := fn(ctx); err != nil { diff --git a/pluginkit/registration.go b/pluginkit/registration.go new file mode 100644 index 0000000..e77105c --- /dev/null +++ b/pluginkit/registration.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import "context" + +// Registration is one compiled plugin's manifest id and the function that registers it from its dependencies. +type Registration[D, P any] struct { + ID string + Register func(D) (P, error) +} + +// RegisterOutcome is what [RegisterEach] answers, both lists in the order of the entries. +type RegisterOutcome[P any] struct { + Registered []P + Failed []Failure +} + +// RegisterEach calls each entry's Register with depsFor of its id, each inside its own recover, past any failure. +func RegisterEach[D, P any](entries []Registration[D, P], depsFor func(id string) D) RegisterOutcome[P] { + var outcome RegisterOutcome[P] + for _, entry := range entries { + plugin, err := register(entry, depsFor(entry.ID)) + if err != nil { + outcome.Failed = append(outcome.Failed, failureOf(entry.ID, StepRegister, err)) + continue + } + outcome.Registered = append(outcome.Registered, plugin) + } + return outcome +} + +// register calls entry's Register with deps inside a recover, naming the plugin in any failure. +func register[D, P any](entry Registration[D, P], deps D) (plugin P, err error) { + err = guarded(context.Background(), entry.ID+" "+string(StepRegister), func(context.Context) error { + var registerErr error + plugin, registerErr = entry.Register(deps) + return registerErr + }) + return plugin, err +} diff --git a/pluginkit/registration_test.go b/pluginkit/registration_test.go new file mode 100644 index 0000000..f40b044 --- /dev/null +++ b/pluginkit/registration_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "errors" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +// listedDeps is what a test lends a plugin when it registers. +type listedDeps struct{ id string } + +// listedPlugin is a plugin a test registered, holding the deps it received. +type listedPlugin struct { + id string + deps listedDeps +} + +// entry is one registration a test lists, its Register noting its call in calls and answering through answer. +func entry(id string, calls *[]string, answer func() error) pluginkit.Registration[listedDeps, *listedPlugin] { + register := func(deps listedDeps) (*listedPlugin, error) { + *calls = append(*calls, "register "+id) + if err := answer(); err != nil { + return nil, err + } + return &listedPlugin{id: id, deps: deps}, nil + } + return pluginkit.Registration[listedDeps, *listedPlugin]{ID: id, Register: register} +} + +// succeeds answers no error. +func succeeds() error { + return nil +} + +// answers returns an answer that is err. +func answers(err error) func() error { + return func() error { return err } +} + +// panicsWith returns an answer that panics with value. +func panicsWith(value any) func() error { + return func() error { panic(value) } +} + +// depsOf returns the deps of each id, noting each call in calls. +func depsOf(calls *[]string) func(id string) listedDeps { + return func(id string) listedDeps { + *calls = append(*calls, "deps "+id) + return listedDeps{id: id} + } +} + +// registeredIDs returns the ids of the plugins in outcome that registered, in order. +func registeredIDs(outcome pluginkit.RegisterOutcome[*listedPlugin]) []string { + ids := []string{} + for _, plugin := range outcome.Registered { + ids = append(ids, plugin.id) + } + return ids +} + +// failures returns the failures of outcome with their errors as text. +func failures(outcome pluginkit.RegisterOutcome[*listedPlugin]) []failureWant { + got := []failureWant{} + for _, failure := range outcome.Failed { + got = append(got, failureWant{failure.ID, failure.Step, failure.Panicked, failure.Err.Error()}) + } + return got +} + +func TestRegisterEachRegistersEveryEntryInOrderAfterBuildingItsDeps(t *testing.T) { + t.Parallel() + + var calls []string + entries := []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("events", &calls, succeeds), entry("feed", &calls, succeeds), entry("gallery", &calls, succeeds), + } + + outcome := pluginkit.RegisterEach(entries, depsOf(&calls)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "feed", "gallery"}) + wantEqual(t, "failed", failures(outcome), []failureWant{}) + wantEqual(t, "calls", calls, []string{ + "deps events", "register events", "deps feed", "register feed", "deps gallery", "register gallery", + }) +} + +func TestRegisterEachHandsEachEntryTheDepsOfItsOwnID(t *testing.T) { + t.Parallel() + + var calls []string + entries := []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("events", &calls, succeeds), entry("feed", &calls, succeeds), + } + + outcome := pluginkit.RegisterEach(entries, depsOf(&calls)) + + for _, plugin := range outcome.Registered { + wantEqual(t, plugin.id+"'s deps", plugin.deps, listedDeps{id: plugin.id}) + } +} + +func TestRegisterEachGoesOnPastAFailingRegisterAndNamesIt(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + answer func() error + panicked bool + text string + }{ + "an error": {answers(errors.New("the title is too long")), false, "register: the title is too long"}, + "a panic": {panicsWith("boom"), true, "register panicked: boom"}, + "an error that holds the text of a panic": { + answers(errors.New("pluginkit: feed register panicked: boom")), false, + "register: pluginkit: feed register panicked: boom", + }, + "a panic whose text panics too": { + panicsWith(selfPanicking{}), true, "register panicked: pluginkit_test.selfPanicking whose text panicked", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var calls []string + entries := []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("events", &calls, succeeds), entry("feed", &calls, tc.answer), entry("gallery", &calls, succeeds), + } + + outcome := pluginkit.RegisterEach(entries, depsOf(&calls)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "gallery"}) + wantEqual(t, "failed", failures(outcome), []failureWant{ + {"feed", pluginkit.StepRegister, tc.panicked, "pluginkit: feed " + tc.text}, + }) + }) + } +} + +func TestRegisterEachWrapsTheErrorARegisterAnswered(t *testing.T) { + t.Parallel() + + var calls []string + tooLong := errors.New("the title is too long") + + outcome := pluginkit.RegisterEach([]pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("feed", &calls, answers(tooLong)), + }, depsOf(&calls)) + + if !errors.Is(outcome.Failed[0].Err, tooLong) { + t.Errorf("feed's error = %v, want it to wrap the error its Register answered", outcome.Failed[0].Err) + } +} + +func TestRegisterEachAnswersAnEmptyOutcomeForNoEntries(t *testing.T) { + t.Parallel() + + var calls []string + + outcome := pluginkit.RegisterEach([]pluginkit.Registration[listedDeps, *listedPlugin]{}, depsOf(&calls)) + + wantEqual(t, "outcome", outcome, pluginkit.RegisterOutcome[*listedPlugin]{}) + wantEqual(t, "calls", calls, []string(nil)) +} + +func TestRegisterEachLetsAPanicWhileBuildingDepsReachTheCaller(t *testing.T) { + t.Parallel() + + var calls []string + entries := []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("events", &calls, succeeds), entry("feed", &calls, succeeds), + } + depsFor := func(id string) listedDeps { + calls = append(calls, "deps "+id) + if id == "feed" { + panic("no settings for feed") + } + return listedDeps{id: id} + } + + defer func() { + wantEqual(t, "the panic", recover(), any("no settings for feed")) + wantEqual(t, "calls", calls, []string{"deps events", "register events", "deps feed"}) + }() + pluginkit.RegisterEach(entries, depsFor) + t.Error("RegisterEach returned, want the panic of depsFor to reach the caller") +} diff --git a/pluginkit/start_each.go b/pluginkit/start_each.go index 1bb2fc9..aa27ddd 100644 --- a/pluginkit/start_each.go +++ b/pluginkit/start_each.go @@ -17,6 +17,8 @@ const ( StepRecord Step = "record" // StepStart is a plugin's Start. StepStart Step = "start" + // StepRegister is a plugin's Register, called by [RegisterEach]. + StepRegister Step = "register" ) // Failure is one step one plugin failed, or was still waiting for when the context ended. diff --git a/pluginkit/start_each_test.go b/pluginkit/start_each_test.go index 9a374fd..b24f569 100644 --- a/pluginkit/start_each_test.go +++ b/pluginkit/start_each_test.go @@ -67,6 +67,19 @@ func TestStartEachStartsEveryPluginInRegistrationOrder(t *testing.T) { wantCalls(t, calls, []string{"alpha start", "beta start", "gamma start"}) } +func TestStartEachNamesAStartErrorWhoseTextPanicsTwiceByItsType(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost(&fakePlugin{id: "beta", startErr: selfPanicking{}, calls: &calls}, plain("gamma", &calls)) + + outcome := startEach(t.Context(), t, host) + + wantOutcome(t, outcome, []string{"gamma"}, []failureWant{{ + "beta", pluginkit.StepStart, true, "pluginkit: beta start panicked: pluginkit_test.selfPanicking whose text panicked", + }}) +} + func TestStartEachMigratesEveryPluginBeforeStartingAny(t *testing.T) { t.Parallel() diff --git a/pluginkit/wire/registration_test.go b/pluginkit/wire/registration_test.go new file mode 100644 index 0000000..cf6fbd7 --- /dev/null +++ b/pluginkit/wire/registration_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "go/format" + "strings" + "testing" +) + +// withRegistrationList returns cfg with the registration list switched on. +func withRegistrationList(cfg Config) Config { + cfg.RegistrationList = true + return cfg +} + +// listedManifests are two backend plugins, one with a hyphen in its id, and one plugin with only a frontend. +func listedManifests() []manifest { + return []manifest{ + {ID: "events", Name: "Events", Backend: "example.com/myapp/plugins/events"}, + {ID: "pretty", Name: "Pretty", Frontend: "@myapp/plugin-pretty"}, + {ID: "photo-gallery", Name: "Photo Gallery", Backend: "example.com/gallery", Frontend: "@myapp/plugin-gallery"}, + } +} + +// listedEntries is the list body of listedManifests. +const listedEntries = ` {ID: "events", Register: func(deps sdk.Deps) (sdk.Plugin, error) { + plugin, err := events.Register(deps) + if err != nil { + return nil, err + } + return plugin, nil + }}, + {ID: "photo-gallery", Register: func(deps sdk.Deps) (sdk.Plugin, error) { + plugin, err := photo_gallery.Register(deps) + if err != nil { + return nil, err + } + return plugin, nil + }}, +` + +// listedFile is the generated file of listedManifests in package pkg, its list in the variable name. +func listedFile(pkg, name string) string { + return "// SPDX-License-Identifier: Apache-2.0\n\n// Code generated by pluginwire. DO NOT EDIT.\n\npackage " + pkg + + "\n\nimport (\n\tphoto_gallery \"example.com/gallery\"\n\tevents \"example.com/myapp/plugins/events\"\n\n" + + "\tpluginKit \"github.com/gopherium/framework/pluginkit\"\n\n\tsdk \"example.com/myapp/sdk\"\n)\n\n" + + "// " + name + " lists every compiled backend plugin's manifest id and Register function, in wiring order.\n" + + "var " + name + " = []pluginKit.Registration[sdk.Deps, sdk.Plugin]{\n" + listedEntries + "}\n" +} + +func TestGenerateWritesARegistrationListOfEveryBackendInWiringOrder(t *testing.T) { + t.Parallel() + + cfg := withRegistrationList(testConfig) + cfg.GoRegistryPackage = "graphroot" + tests := map[string]struct{ got, want string }{ + "the main file": {string(generateGo(cfg, listedManifests())), listedFile("main", "pluginRegistrations")}, + "the registry": {string(generateRegistry(cfg, listedManifests())), listedFile("graphroot", "Registrations")}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.got != tc.want { + t.Errorf("generated = %q, want %q", tc.got, tc.want) + } + }) + } +} + +func TestGenerateGoWritesAnEmptyRegistrationListWithoutBackendPlugins(t *testing.T) { + t.Parallel() + + got := string(generateGo(withRegistrationList(testConfig), []manifest{ + {ID: "pretty", Name: "Pretty", Frontend: "@myapp/plugin-pretty"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +package main + +import ( + pluginKit "github.com/gopherium/framework/pluginkit" + + sdk "example.com/myapp/sdk" +) + +// pluginRegistrations lists every compiled backend plugin's manifest id and Register function, in wiring order. +var pluginRegistrations = []pluginKit.Registration[sdk.Deps, sdk.Plugin]{} +` + if got != want { + t.Errorf("generateGo() = %q, want %q", got, want) + } +} + +func TestGeneratedRegistrationListsAreFormattedAsGofmtFormatsThem(t *testing.T) { + t.Parallel() + + cfg := withRegistrationList(testConfig) + cfg.GoRegistryPackage = "graphroot" + files := map[string][]byte{ + "a list": generateGo(cfg, listedManifests()), + "an empty one": generateGo(cfg, nil), + "a registry": generateRegistry(cfg, listedManifests()), + } + for name, src := range files { + t.Run(name, func(t *testing.T) { + t.Parallel() + + formatted, err := format.Source(src) + if err != nil || string(formatted) != string(src) { + t.Errorf("format.Source() = %q, %v, want the source unchanged", formatted, err) + } + }) + } +} + +func TestRunWritesTheRegistrationListInPlaceOfBothFunctions(t *testing.T) { + t.Parallel() + + cfg := withRegistrationList(testConfig) + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + cfg.GoRegistryPackage = "graphroot" + + files := generate(t, cfg, map[string]string{"feed": feedManifest}) + + checks := map[string][2]string{ + cfg.GoWiringPath: {"var pluginRegistrations = ", "func registerPlugins("}, + cfg.GoRegistryPath: {"var Registrations = ", "func All("}, + } + for path, check := range checks { + if !strings.Contains(files[path], check[0]) || strings.Contains(files[path], check[1]) { + t.Errorf("%s = %q, want %q in place of %q", path, files[path], check[0], check[1]) + } + } +} diff --git a/pluginkit/wire/wire.go b/pluginkit/wire/wire.go index 6b4b6f7..a95a69e 100644 --- a/pluginkit/wire/wire.go +++ b/pluginkit/wire/wire.go @@ -33,6 +33,9 @@ type Config struct { // TSScreensPath is the optional slash path of a generated TypeScript map from each plugin id to its screens import. TSScreensPath string + // RegistrationList makes each generated Go file list every backend plugin's id and Register in place of its function. + RegistrationList bool + // ManifestIndexPath is the optional slash path of a generated JSON list of every manifest, in wiring order. ManifestIndexPath string @@ -182,6 +185,9 @@ func generatedHeader(license string) string { // generateGo renders the generated Go plugin-wiring file. func generateGo(cfg Config, manifests []manifest) []byte { + if cfg.RegistrationList { + return renderRegistrationList(cfg, manifests, listTarget{pkg: "main", name: "pluginRegistrations"}) + } doc := "// registerPlugins registers every compiled plugin, answering the ones that registered and an error naming " + "each failure.\n" return renderRegistration(cfg, manifests, "main", doc, "registerPlugins") @@ -189,10 +195,62 @@ func generateGo(cfg Config, manifests []manifest) []byte { // generateRegistry renders the generated importable plugin registry file. func generateRegistry(cfg Config, manifests []manifest) []byte { + if cfg.RegistrationList { + return renderRegistrationList(cfg, manifests, listTarget{pkg: cfg.GoRegistryPackage, name: "Registrations"}) + } doc := "// All registers every plugin, answering the ones that registered and an error naming each failure.\n" return renderRegistration(cfg, manifests, cfg.GoRegistryPackage, doc, "All") } +// pluginkitImport is the import path of the package that declares Registration. +const pluginkitImport = "github.com/gopherium/framework/pluginkit" + +// registrationEntry is one entry of a generated registration list, formatted with a plugin's id and import name. +const registrationEntry = ` {ID: %q, Register: func(deps sdk.Deps) (sdk.Plugin, error) { + plugin, err := %s.Register(deps) + if err != nil { + return nil, err + } + return plugin, nil + }}, +` + +// listTarget is the package and the variable name of one generated registration list. +type listTarget struct { + pkg, name string +} + +// renderRegistrationList renders one Go file holding target's variable, a list of every backend plugin's Register. +func renderRegistrationList(cfg Config, manifests []manifest, target listTarget) []byte { + backends := slices.DeleteFunc(slices.Clone(manifests), func(m manifest) bool { return m.Backend == "" }) + var b strings.Builder + b.WriteString(generatedHeader(cfg.License)) + fmt.Fprintf(&b, "package %s\n\nimport (\n", target.pkg) + byPath := slices.SortedStableFunc(slices.Values(backends), func(a, b manifest) int { + return strings.Compare(a.Backend, b.Backend) + }) + for _, m := range byPath { + fmt.Fprintf(&b, "\t%s %q\n", goName(m.ID), m.Backend) + } + if len(backends) > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "\tpluginKit %q\n\n\tsdk %q\n)\n\n", pluginkitImport, cfg.SDKImport) + fmt.Fprintf(&b, "// %s lists every compiled backend plugin's manifest id and Register function, "+ + "in wiring order.\n", target.name) + fmt.Fprintf(&b, "var %s = []pluginKit.Registration[sdk.Deps, sdk.Plugin]{", target.name) + if len(backends) == 0 { + b.WriteString("}\n") + return []byte(b.String()) + } + b.WriteString("\n") + for _, m := range backends { + fmt.Fprintf(&b, registrationEntry, m.ID, goName(m.ID)) + } + b.WriteString("}\n") + return []byte(b.String()) +} + // renderRegistration renders one Go file registering every backend plugin through the named function. func renderRegistration(cfg Config, manifests []manifest, pkg, doc, funcName string) []byte { backends := make([]manifest, 0, len(manifests))