diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index d3f1e0a..b31a807 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -3,6 +3,13 @@ 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 + +- `wire.Manifest` reads `menu` as `[]MenuEntry`, and `screens` and `screen_capabilities`, which no longer sit in `Extra`, a breaking change. +- `wire.Run` refuses a menu entry with no `label`, `screen` or `capability`, a screen name that is not one path segment, two menu entries asking different capabilities for one screen, and a screen capability that is empty or names a screen of the menu, a breaking change for a `menu` that ran before. +- `wire.Config` gains an optional `TSScreensPath`, a generated TypeScript map from each plugin id to a lazy import of its `screens` module, in wiring order. +- With `PackageJSONPath` set, the package a `screens` module names joins the workspace dependencies, and a `screens` module that names no package is refused. + ## 0.10.0 - 2026-11-07 - `NewHook` collects the plugins that implement an interface into a `Hook`. `Hook.Fire` calls an action on each in registration order and `Filter` passes a value through each, every call inside its own recover. diff --git a/pluginkit/features/features_test.go b/pluginkit/features/features_test.go index c339027..3ffc567 100644 --- a/pluginkit/features/features_test.go +++ b/pluginkit/features/features_test.go @@ -37,3 +37,7 @@ func runFeature(t *testing.T, path string, initialize func(*godog.ScenarioContex func TestHooks(t *testing.T) { runFeature(t, "hooks.feature", initializeHooks(t)) } + +func TestScreens(t *testing.T) { + runFeature(t, "screens.feature", initializeScreens(t)) +} diff --git a/pluginkit/features/screens.feature b/pluginkit/features/screens.feature new file mode 100644 index 0000000..a7c8a6f --- /dev/null +++ b/pluginkit/features/screens.feature @@ -0,0 +1,165 @@ +Feature: Admin menus and lazy screens from the manifest + A plugin's manifest can list its admin menu entries, name the module + that holds its admin screens, and name the capability each screen needs + when no menu entry links to it. The wiring generator checks the shape of + those keys. When the application asks for it, the generator also writes + a file that imports a plugin's screens module only when one of its + screens opens, so an admin with many plugins never downloads every + plugin's screens at once. A screens module does not replace the + frontend, so a plugin can carry both while it moves. + + Scenario: The menu, the screens module and the screen capabilities are read as data + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry {"label": "Reservations", "screen": "reservations", "capability": "events.manage"} + And "events" names "events.manage" for the screen "seats" + When the manifests are read + Then the manifest of "events" has the menu entry {"label": "Reservations", "screen": "reservations", "capability": "events.manage"} + And the manifest of "events" names the screens module "@example/plugin-events/screens" + And the manifest of "events" names "events.manage" for the screen "seats" + And the manifest of "events" keeps no extra key + + Scenario: The lazy screens file imports each plugin's screens module in wiring order + Given the plugin "billing" has a backend + And the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And the plugin "gallery" has a backend and the screens module "@example/plugin-gallery/screens" + And the application asks for a lazy screens file + When the plugins are wired + Then the lazy screens file maps these plugins, in order: + | plugin | module | + | events | @example/plugin-events/screens | + | gallery | @example/plugin-gallery/screens | + + Scenario: A plugin that keeps its frontend loads as before beside one that moved + Given the plugin "events" has the frontend "@example/plugin-events" and the screens module "@example/plugin-events/screens" + And the plugin "gallery" has the frontend "@example/plugin-gallery" + And the application asks for a lazy screens file + When the plugins are wired + Then the frontend wiring imports "@example/plugin-events" and "@example/plugin-gallery" + And the lazy screens file maps these plugins, in order: + | plugin | module | + | events | @example/plugin-events/screens | + + Scenario: The lazy screens file is an empty map when no plugin names a screens module + Given the plugin "gallery" has the frontend "@example/plugin-gallery" + And the application asks for a lazy screens file + When the plugins are wired + Then the lazy screens file maps no plugin + + Scenario: Without a lazy screens file the generator writes only today's files + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + When the plugins are wired + Then the generator writes only the Go and the TypeScript wiring + + Scenario: A screens module does not count as a frontend + Given the plugin "events" has only the screens module "@example/plugin-events/screens" + When the plugins are wired + Then the wiring is refused with: pluginwire: plugins/events/plugin.json: at least one of backend or frontend is required + + Scenario Outline: A plugin with a screens module keeps its package in the package.json + Given the application keeps its plugin packages in a package.json + And the plugin "events" has a backend and the screens module "" + When the plugins are wired + Then the package.json depends only on "" as a workspace package + + Examples: + | module | package | + | @example/plugin-events/screens | @example/plugin-events | + | @example/plugin-events | @example/plugin-events | + | plugin-events/screens | plugin-events | + | plugin-events | plugin-events | + + Scenario: A frontend and a screens module from one package are one dependency + Given the application keeps its plugin packages in a package.json + And the plugin "events" has the frontend "@example/plugin-events" and the screens module "@example/plugin-events/screens" + When the plugins are wired + Then the package.json depends only on "@example/plugin-events" as a workspace package + + Scenario Outline: A screens module that names no package is refused while a package.json is kept + Given the application keeps its plugin packages in a package.json + And the plugin "events" has a backend and the screens module "" + When the plugins are wired + Then the wiring is refused with: pluginwire: package.json: events: the screens module "" names no package + And the files of the earlier wiring are left as they were + + Examples: + | module | + | ./screens | + | /admin/screens/events.js | + | @example | + | https://example.com/events.js | + + Scenario: A screens module with no package is kept when the application keeps no package.json + Given the plugin "events" has a backend and the screens module "./screens" + And the application asks for a lazy screens file + When the plugins are wired + Then the lazy screens file maps these plugins, in order: + | plugin | module | + | events | ./screens | + + Scenario Outline: A menu entry with a wrong shape is refused + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry + When the plugins are wired + Then the wiring is refused with: pluginwire: plugins/events/plugin.json: + And the files of the earlier wiring are left as they were + + Examples: + | entry | error | + | {"screen": "reservations", "capability": "events.manage"} | menu[0]: label is required | + | {"label": "Reservations", "capability": "events.manage"} | menu[0]: screen is required | + | {"label": "Reservations", "screen": "reservations"} | menu[0]: capability is required | + | {"label": "Reservations", "screen": "reservations", "capability": ""} | menu[0]: capability is required | + | {"label": "Reservations", "screen": "list/all", "capability": "events.manage"} | menu[0]: the screen "list/all" must be one path segment | + | {"label": "Reservations", "screen": "reservations", "capability": "events.manage", "position": "first"} | json: cannot unmarshal string into Go struct field manifest.menu.0.position of type int | + + Scenario Outline: A screen capability with a wrong shape is refused + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the screen capabilities + When the plugins are wired + Then the wiring is refused with: pluginwire: plugins/events/plugin.json: + + Examples: + | capabilities | error | + | {"seats": ""} | screen_capabilities: the screen "seats" needs a capability | + | {"": "events.manage"} | screen_capabilities: the screen "" must be one path segment | + | {"seats/all": "events.manage"} | screen_capabilities: the screen "seats/all" must be one path segment | + | {"seats": 3} | json: cannot unmarshal number into Go struct field manifest.screen_capabilities.seats of type string | + + Scenario: A screen capability for a screen a menu entry names is refused + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry {"label": "Reservations", "screen": "reservations", "capability": "events.manage"} + And "events" names "events.view" for the screen "reservations" + When the plugins are wired + Then the wiring is refused with: pluginwire: plugins/events/plugin.json: screen_capabilities: the screen "reservations" takes the capability of menu[0] + + Scenario: Two menu entries that ask different capabilities for one screen are refused + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry {"label": "Reservations", "screen": "reservations", "capability": "events.manage"} + And "events" has the menu entry {"label": "Bookings", "screen": "reservations", "parent": "content", "capability": "events.view"} + When the plugins are wired + Then the wiring is refused with: pluginwire: plugins/events/plugin.json: menu[1]: the screen "reservations" asks for "events.view" but menu[0] asks for "events.manage" + + Scenario: Two menu entries can open one screen that asks for one capability + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry {"label": "Reservations", "screen": "reservations", "capability": "events.manage"} + And "events" has the menu entry {"label": "Bookings", "screen": "reservations", "parent": "content", "capability": "events.manage"} + When the manifests are read + Then the manifest of "events" has the menu entry {"label": "Bookings", "screen": "reservations", "parent": "content", "capability": "events.manage"} + + Scenario: The manifest index writes the menu, the screens module and the screen capabilities + Given the plugin "events" has a backend and the screens module "@example/plugin-events/screens" + And "events" has the menu entry {"label": "Reservations", "screen": "reservations", "parent": "content", "capability": "events.manage"} + And "events" names "events.manage" for the screen "seats" + And the application asks for a manifest index + When the plugins are wired + Then the manifest index holds this entry for "events": + """ + { + "id": "events", + "name": "events", + "backend": "example.com/plugins/events", + "menu": [{"label": "Reservations", "screen": "reservations", "parent": "content", "capability": "events.manage"}], + "screens": "@example/plugin-events/screens", + "screen_capabilities": {"seats": "events.manage"} + } + """ diff --git a/pluginkit/features/steps_screens_test.go b/pluginkit/features/steps_screens_test.go new file mode 100644 index 0000000..3a900f3 --- /dev/null +++ b/pluginkit/features/steps_screens_test.go @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: Apache-2.0 + +package features_test + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "github.com/cucumber/godog" + + "github.com/gopherium/framework/pluginkit/wire" +) + +// earlierWiring is what the wiring files hold before a scenario wires its plugins. +const earlierWiring = "earlier wiring\n" + +// screensConfig is the wiring a scenario starts from, before it asks for more. +var screensConfig = wire.Config{ + SDKImport: "example.com/app/sdk", + FrontendSDK: "@example/frontend-sdk", + GoWiringPath: "plugins_gen.go", + TSWiringPath: "plugins.ts", + License: "Apache-2.0", +} + +// screenImport finds one plugin id and its screens module in the lazy screens file. +var screenImport = regexp.MustCompile(`(?m)^\t("(?:[^"\\]|\\.)*"): \(\) => import\(("(?:[^"\\]|\\.)*")\),$`) + +// screensWorld is what one scenario of the screens feature builds and finds. +type screensWorld struct { + t *testing.T + root string + manifests map[string]map[string]any + cfg wire.Config + read []wire.Manifest + err error +} + +// plugin returns the manifest of id, starting it with its id and name. +func (w *screensWorld) plugin(id string) map[string]any { + m, ok := w.manifests[id] + if !ok { + m = map[string]any{"id": id, "name": id} + w.manifests[id] = m + } + return m +} + +// pluginParts are the code a scenario gives a plugin besides its id and name. +type pluginParts struct { + backend bool + frontend string + screens string +} + +// declare gives the plugin id its parts. +func (w *screensWorld) declare(id string, parts pluginParts) { + m := w.plugin(id) + if parts.backend { + m["backend"] = "example.com/plugins/" + id + } + if parts.frontend != "" { + m["frontend"] = parts.frontend + } + if parts.screens != "" { + m["screens"] = parts.screens + } +} + +// hasBackend gives the plugin id a backend. +func (w *screensWorld) hasBackend(id string) { + w.declare(id, pluginParts{backend: true}) +} + +// hasBackendAndScreens gives the plugin id a backend and a screens module. +func (w *screensWorld) hasBackendAndScreens(id, screens string) { + w.declare(id, pluginParts{backend: true, screens: screens}) +} + +// hasFrontend gives the plugin id a frontend. +func (w *screensWorld) hasFrontend(id, frontend string) { + w.declare(id, pluginParts{frontend: frontend}) +} + +// hasFrontendAndScreens gives the plugin id a frontend and a screens module. +func (w *screensWorld) hasFrontendAndScreens(id, frontend, screens string) { + w.declare(id, pluginParts{frontend: frontend, screens: screens}) +} + +// hasOnlyScreens gives the plugin id a screens module and nothing else. +func (w *screensWorld) hasOnlyScreens(id, screens string) { + w.declare(id, pluginParts{screens: screens}) +} + +// hasMenuEntry adds the menu entry written as JSON to the plugin id. +func (w *screensWorld) hasMenuEntry(id, entry string) { + m := w.plugin(id) + menu, _ := m["menu"].([]json.RawMessage) + m["menu"] = append(menu, json.RawMessage(entry)) +} + +// hasScreenCapabilities gives the plugin id the screen capabilities written as JSON. +func (w *screensWorld) hasScreenCapabilities(id, capabilities string) { + w.plugin(id)["screen_capabilities"] = json.RawMessage(capabilities) +} + +// namesCapability names the capability a screen of the plugin id needs. +func (w *screensWorld) namesCapability(id, capability, screen string) { + m := w.plugin(id) + capabilities, ok := m["screen_capabilities"].(map[string]string) + if !ok { + capabilities = map[string]string{} + m["screen_capabilities"] = capabilities + } + capabilities[screen] = capability +} + +// asksForScreens switches the lazy screens file on. +func (w *screensWorld) asksForScreens() { + w.cfg.TSScreensPath = "screens.ts" +} + +// asksForIndex switches the manifest index on. +func (w *screensWorld) asksForIndex() { + w.cfg.ManifestIndexPath = "index.json" +} + +// keepsPackageJSON switches the package.json on, starting from one that names no plugin. +func (w *screensWorld) keepsPackageJSON() { + w.cfg.PackageJSONPath = "package.json" +} + +// writeFile writes data to the slash path under the scenario's root. +func (w *screensWorld) writeFile(path string, data []byte) 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, data, 0o644) +} + +// readFile returns the slash path under the scenario's root. +func (w *screensWorld) readFile(path string) (string, error) { + data, err := os.ReadFile(filepath.Join(w.root, filepath.FromSlash(path))) + return string(data), err +} + +// writeTree writes every manifest, the earlier wiring and any package.json under a new root. +func (w *screensWorld) writeTree() error { + w.root = w.t.TempDir() + for id, m := range w.manifests { + data, err := json.Marshal(m) + if err != nil { + return err + } + if err := w.writeFile("plugins/"+id+"/plugin.json", data); err != nil { + return err + } + } + for _, path := range []string{w.cfg.GoWiringPath, w.cfg.TSWiringPath} { + if err := w.writeFile(path, []byte(earlierWiring)); err != nil { + return err + } + } + if w.cfg.PackageJSONPath == "" { + return nil + } + return w.writeFile(w.cfg.PackageJSONPath, []byte(`{"name": "@example/admin"}`)) +} + +// readManifests reads every manifest the scenario wrote. +func (w *screensWorld) readManifests() error { + if err := w.writeTree(); err != nil { + return err + } + w.read, w.err = wire.Load(w.root, []string{"plugins"}, nil) + return nil +} + +// wirePlugins runs the generator over the manifests the scenario wrote. +func (w *screensWorld) wirePlugins() error { + if err := w.writeTree(); err != nil { + return err + } + w.err = wire.Run(w.root, w.cfg) + return nil +} + +// wired returns the error that stopped the run, if one did. +func (w *screensWorld) wired() error { + if w.err != nil { + return fmt.Errorf("the run failed: %w", w.err) + } + return nil +} + +// manifestOf returns the manifest of id as the scenario read it. +func (w *screensWorld) manifestOf(id string) (wire.Manifest, error) { + if err := w.wired(); err != nil { + return wire.Manifest{}, err + } + i := slices.IndexFunc(w.read, func(m wire.Manifest) bool { return m.ID == id }) + if i < 0 { + return wire.Manifest{}, fmt.Errorf("no manifest of %s was read", id) + } + return w.read[i], nil +} + +// readMenuEntry checks that the manifest of id holds the menu entry written as JSON. +func (w *screensWorld) readMenuEntry(id, entry string) error { + m, err := w.manifestOf(id) + if err != nil { + return err + } + var want wire.MenuEntry + if err := json.Unmarshal([]byte(entry), &want); err != nil { + return err + } + if !slices.ContainsFunc(m.Menu, func(got wire.MenuEntry) bool { return reflect.DeepEqual(got, want) }) { + return fmt.Errorf("the menu of %s is %+v, want it to hold %+v", id, m.Menu, want) + } + return nil +} + +// readScreens checks that the manifest of id names the screens module. +func (w *screensWorld) readScreens(id, screens string) error { + m, err := w.manifestOf(id) + if err != nil { + return err + } + if m.Screens != screens { + return fmt.Errorf("the screens module of %s is %q, want %q", id, m.Screens, screens) + } + return nil +} + +// readCapability checks that the manifest of id names the capability of the screen. +func (w *screensWorld) readCapability(id, capability, screen string) error { + m, err := w.manifestOf(id) + if err != nil { + return err + } + if got := m.ScreenCapabilities[screen]; got != capability { + return fmt.Errorf("the screen %s of %s needs %q, want %q", screen, id, got, capability) + } + return nil +} + +// readNoExtra checks that the manifest of id keeps every key in a typed field. +func (w *screensWorld) readNoExtra(id string) error { + m, err := w.manifestOf(id) + if err != nil { + return err + } + if len(m.Extra) > 0 { + return fmt.Errorf("the manifest of %s keeps %d extra keys, want none", id, len(m.Extra)) + } + return nil +} + +// screenImports returns every plugin id of the lazy screens file with its screens module, in order. +func (w *screensWorld) screenImports() ([][2]string, error) { + if err := w.wired(); err != nil { + return nil, err + } + text, err := w.readFile(w.cfg.TSScreensPath) + if err != nil { + return nil, err + } + var imports [][2]string + for _, match := range screenImport.FindAllStringSubmatch(text, -1) { + var entry [2]string + err := errors.Join(json.Unmarshal([]byte(match[1]), &entry[0]), json.Unmarshal([]byte(match[2]), &entry[1])) + if err != nil { + return nil, err + } + imports = append(imports, entry) + } + return imports, nil +} + +// mapsInOrder checks that the lazy screens file imports the module of each plugin of the table, in its order. +func (w *screensWorld) mapsInOrder(table *godog.Table) error { + got, err := w.screenImports() + if err != nil { + return err + } + var want [][2]string + for _, row := range table.Rows[1:] { + want = append(want, [2]string{row.Cells[0].Value, row.Cells[1].Value}) + } + if !slices.Equal(got, want) { + return fmt.Errorf("the lazy screens file maps %q, want %q", got, want) + } + return nil +} + +// mapsNoPlugin checks that the lazy screens file is an empty map. +func (w *screensWorld) mapsNoPlugin() error { + got, err := w.screenImports() + if err != nil { + return err + } + text, err := w.readFile(w.cfg.TSScreensPath) + if err != nil { + return err + } + if len(got) > 0 || !strings.HasSuffix(text, "= {}\n") { + return fmt.Errorf("the lazy screens file is %q, want an empty map", text) + } + return nil +} + +// importsFrontends checks that the TypeScript wiring imports both frontends. +func (w *screensWorld) importsFrontends(first, second string) error { + if err := w.wired(); err != nil { + return err + } + text, err := w.readFile(w.cfg.TSWiringPath) + if err != nil { + return err + } + for _, frontend := range []string{first, second} { + if !strings.Contains(text, "from '"+frontend+"'\n") { + return fmt.Errorf("the TypeScript wiring is %q, want it to import %s", text, frontend) + } + } + return nil +} + +// writesOnlyWiring checks that the run wrote the Go and TypeScript wiring and no other file. +func (w *screensWorld) writesOnlyWiring() error { + if err := w.wired(); err != nil { + return err + } + entries, err := os.ReadDir(w.root) + if err != nil { + return err + } + var files []string + for _, entry := range entries { + files = append(files, entry.Name()) + } + if want := []string{"plugins", "plugins.ts", "plugins_gen.go"}; !slices.Equal(files, want) { + return fmt.Errorf("the root holds %q, want only %q", files, want) + } + if kept, err := w.readFile(w.cfg.TSWiringPath); err != nil || kept == earlierWiring { + return fmt.Errorf("the TypeScript wiring was not written: %v", err) + } + return nil +} + +// refusedWith checks that the run failed with the text, the scenario's root left out of its paths. +func (w *screensWorld) refusedWith(text string) error { + if w.err == nil { + return errors.New("the run succeeded, want it refused") + } + if got := strings.ReplaceAll(w.err.Error(), w.root+string(filepath.Separator), ""); got != text { + return fmt.Errorf("the run failed with %q, want %q", got, text) + } + return nil +} + +// earlierWiringKept checks that both wiring files still hold the earlier wiring. +func (w *screensWorld) earlierWiringKept() error { + for _, path := range []string{w.cfg.GoWiringPath, w.cfg.TSWiringPath} { + kept, err := w.readFile(path) + if err != nil || kept != earlierWiring { + return fmt.Errorf("%s holds %q, want the earlier wiring: %v", path, kept, err) + } + } + return nil +} + +// dependsOnlyOn checks that the package.json depends on the one package as a workspace package. +func (w *screensWorld) dependsOnlyOn(name string) error { + if err := w.wired(); err != nil { + return err + } + text, err := w.readFile(w.cfg.PackageJSONPath) + if err != nil { + return err + } + var packageJSON struct{ Dependencies map[string]string } + if err := json.Unmarshal([]byte(text), &packageJSON); err != nil { + return err + } + if want := map[string]string{name: "workspace:*"}; !reflect.DeepEqual(packageJSON.Dependencies, want) { + return fmt.Errorf("the package.json depends on %v, want %v", packageJSON.Dependencies, want) + } + return nil +} + +// indexHolds checks that the manifest index holds the entry of id written as JSON. +func (w *screensWorld) indexHolds(id string, entry *godog.DocString) error { + if err := w.wired(); err != nil { + return err + } + text, err := w.readFile(w.cfg.ManifestIndexPath) + if err != nil { + return err + } + var index []map[string]any + var want map[string]any + if err := errors.Join(json.Unmarshal([]byte(text), &index), json.Unmarshal([]byte(entry.Content), &want)); err != nil { + return err + } + i := slices.IndexFunc(index, func(m map[string]any) bool { return m["id"] == id }) + if i < 0 || !reflect.DeepEqual(index[i], want) { + return fmt.Errorf("the manifest index is %s, want it to hold %s", text, entry.Content) + } + return nil +} + +// givenScreens registers the steps that write manifests and ask for outputs. +func givenScreens(sc *godog.ScenarioContext, w *screensWorld) { + sc.Step(`^the plugin "([^"]*)" has a backend$`, w.hasBackend) + sc.Step(`^the plugin "([^"]*)" has a backend and the screens module "([^"]*)"$`, w.hasBackendAndScreens) + sc.Step(`^the plugin "([^"]*)" has the frontend "([^"]*)"$`, w.hasFrontend) + sc.Step(`^the plugin "([^"]*)" has the frontend "([^"]*)" and the screens module "([^"]*)"$`, + w.hasFrontendAndScreens) + sc.Step(`^the plugin "([^"]*)" has only the screens module "([^"]*)"$`, w.hasOnlyScreens) + sc.Step(`^"([^"]*)" has the menu entry (\{.*\})$`, w.hasMenuEntry) + sc.Step(`^"([^"]*)" has the screen capabilities (\{.*\})$`, w.hasScreenCapabilities) + sc.Step(`^"([^"]*)" names "([^"]*)" for the screen "([^"]*)"$`, w.namesCapability) + sc.Step(`^the application asks for a lazy screens file$`, w.asksForScreens) + sc.Step(`^the application asks for a manifest index$`, w.asksForIndex) + sc.Step(`^the application keeps its plugin packages in a package\.json$`, w.keepsPackageJSON) +} + +// whenScreens registers the steps that read the manifests or wire the plugins. +func whenScreens(sc *godog.ScenarioContext, w *screensWorld) { + sc.Step(`^the manifests are read$`, w.readManifests) + sc.Step(`^the plugins are wired$`, w.wirePlugins) +} + +// thenScreens registers the steps that check what was read or written. +func thenScreens(sc *godog.ScenarioContext, w *screensWorld) { + sc.Step(`^the manifest of "([^"]*)" has the menu entry (\{.*\})$`, w.readMenuEntry) + sc.Step(`^the manifest of "([^"]*)" names the screens module "([^"]*)"$`, w.readScreens) + sc.Step(`^the manifest of "([^"]*)" names "([^"]*)" for the screen "([^"]*)"$`, w.readCapability) + sc.Step(`^the manifest of "([^"]*)" keeps no extra key$`, w.readNoExtra) + sc.Step(`^the lazy screens file maps these plugins, in order:$`, w.mapsInOrder) + sc.Step(`^the lazy screens file maps no plugin$`, w.mapsNoPlugin) + sc.Step(`^the frontend wiring imports "([^"]*)" and "([^"]*)"$`, w.importsFrontends) + sc.Step(`^the generator writes only the Go and the TypeScript wiring$`, w.writesOnlyWiring) + sc.Step(`^the wiring is refused with: (.+)$`, w.refusedWith) + sc.Step(`^the files of the earlier wiring are left as they were$`, w.earlierWiringKept) + sc.Step(`^the package\.json depends only on "([^"]*)" as a workspace package$`, w.dependsOnlyOn) + sc.Step(`^the manifest index holds this entry for "([^"]*)":$`, w.indexHolds) +} + +// initializeScreens gives each scenario of the screens feature a world of its own and its steps. +func initializeScreens(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { + w := &screensWorld{t: t, manifests: map[string]map[string]any{}, cfg: screensConfig} + givenScreens(sc, w) + whenScreens(sc, w) + thenScreens(sc, w) + } +} diff --git a/pluginkit/wire/manifest.go b/pluginkit/wire/manifest.go index 94d116d..c7841a0 100644 --- a/pluginkit/wire/manifest.go +++ b/pluginkit/wire/manifest.go @@ -32,6 +32,11 @@ type Manifest struct { Defines []Define `json:"defines,omitempty"` Jobs []Job `json:"jobs,omitempty"` Settings []Setting `json:"settings,omitempty"` + Menu []MenuEntry `json:"menu,omitempty"` + // Screens is the address of the module the admin imports only when one of the plugin's screens opens. + Screens string `json:"screens,omitempty"` + // ScreenCapabilities maps each screen no menu entry names to the capability it needs. + ScreenCapabilities map[string]string `json:"screen_capabilities,omitempty"` // Extra holds every other key of the manifest as raw JSON, for the application to check and read. Extra map[string]json.RawMessage `json:"-"` @@ -99,13 +104,23 @@ type Setting struct { Of string `json:"of,omitempty"` } +// MenuEntry is one admin menu entry of a plugin, opening one of its screens for a viewer with its capability. +type MenuEntry struct { + Label string `json:"label"` + Screen string `json:"screen"` + Parent string `json:"parent,omitempty"` + Position *int `json:"position,omitempty"` + Capability string `json:"capability"` + Icon string `json:"icon,omitempty"` +} + // manifest is a Manifest with no JSON methods, so decoding it stays plain and its errors name manifest. type manifest Manifest // typedKeys are the manifest keys Manifest has a field for. var typedKeys = []string{ "id", "name", "backend", "frontend", "version", "license", "did", "requires", "tested", "engines", "uninstall", - "hooks", "defines", "jobs", "settings", + "hooks", "defines", "jobs", "settings", "menu", "screens", "screen_capabilities", } // isTypedKey reports whether encoding/json reads key into a field of Manifest. @@ -192,6 +207,60 @@ func checkSettings(settings []Setting) error { return nil } +// checkMenuEntry refuses menu entry i with no label, no screen or no capability, or a screen that is not one segment. +func checkMenuEntry(i int, entry MenuEntry) error { + if entry.Label == "" { + return fmt.Errorf("menu[%d]: label is required", i) + } + if entry.Screen == "" { + return fmt.Errorf("menu[%d]: screen is required", i) + } + if entry.Capability == "" { + return fmt.Errorf("menu[%d]: capability is required", i) + } + if strings.Contains(entry.Screen, "/") { + return fmt.Errorf("menu[%d]: the screen %q must be one path segment", i, entry.Screen) + } + return nil +} + +// checkMenu refuses an incomplete menu entry, or one asking another capability for a screen an earlier entry names. +func checkMenu(menu []MenuEntry) error { + first := make(map[string]int, len(menu)) + for i, entry := range menu { + if err := checkMenuEntry(i, entry); err != nil { + return err + } + earlier, named := first[entry.Screen] + if !named { + first[entry.Screen] = i + continue + } + if asked := menu[earlier].Capability; asked != entry.Capability { + return fmt.Errorf("menu[%d]: the screen %q asks for %q but menu[%d] asks for %q", + i, entry.Screen, entry.Capability, earlier, asked) + } + } + return nil +} + +// checkScreenCapabilities refuses a screen that is not one segment, an empty capability or a screen of the menu. +func checkScreenCapabilities(capabilities map[string]string, menu []MenuEntry) error { + for _, screen := range slices.Sorted(maps.Keys(capabilities)) { + if screen == "" || strings.Contains(screen, "/") { + return fmt.Errorf("screen_capabilities: the screen %q must be one path segment", screen) + } + if capabilities[screen] == "" { + return fmt.Errorf("screen_capabilities: the screen %q needs a capability", screen) + } + named := slices.IndexFunc(menu, func(entry MenuEntry) bool { return entry.Screen == screen }) + if named >= 0 { + return fmt.Errorf("screen_capabilities: the screen %q takes the capability of menu[%d]", screen, named) + } + } + return nil +} + // checkShape refuses an empty string where the shape of a manifest asks for one, naming the first such entry. func checkShape(m manifest) error { return cmp.Or( @@ -200,6 +269,8 @@ func checkShape(m manifest) error { checkDefines(m.Defines), checkJobs(m.Jobs), checkSettings(m.Settings), + checkMenu(m.Menu), + checkScreenCapabilities(m.ScreenCapabilities, m.Menu), ) } diff --git a/pluginkit/wire/manifest_identity_test.go b/pluginkit/wire/manifest_identity_test.go index 40608e4..c8e2903 100644 --- a/pluginkit/wire/manifest_identity_test.go +++ b/pluginkit/wire/manifest_identity_test.go @@ -26,6 +26,7 @@ const everyNewKey = `"version": "2.0.0", "settings": [{"key": "max_seats", "type": "count", "default": "4", "min": 1, "max": 50}], "menu": [{"label": "Reservations", "screen": "reservations", "capability": "events.manage"}], "screens": "@myapp/plugin-feed/screens", + "screen_capabilities": {"seats": "events.manage"}, "pages": [{"word": "forums"}], "islands": ["reply-form"], "blocks": ["blocks/latest-topics"], diff --git a/pluginkit/wire/manifest_index_test.go b/pluginkit/wire/manifest_index_test.go index 121ce04..83c23a8 100644 --- a/pluginkit/wire/manifest_index_test.go +++ b/pluginkit/wire/manifest_index_test.go @@ -95,13 +95,26 @@ const fullIndex = `[ "of": "venue" } ], - "graphql": true, "menu": [ { - "label": "Reservations" + "label": "Reservations", + "screen": "reservations", + "parent": "content", + "position": 30, + "capability": "events.manage", + "icon": "calendar" + }, + { + "label": "Check-in", + "screen": "check-in", + "capability": "events.checkin" } ], - "screens": "@myapp/plugin-events/screens" + "screens": "@myapp/plugin-events/screens", + "screen_capabilities": { + "seats": "events.manage" + }, + "graphql": true }, { "id": "feed", @@ -191,6 +204,18 @@ func TestManifestIndexKeepsAZeroLimit(t *testing.T) { } } +func TestManifestIndexKeepsAZeroPosition(t *testing.T) { + t.Parallel() + + got := runForIndex(t, map[string]string{ + "feed": withKeys(feedManifest, `"menu": [{"label": "a", "screen": "b", "capability": "c", "position": 0}]`), + }) + + if !strings.Contains(got, `"position": 0,`) { + t.Errorf("index = %s, want the position kept at zero", got) + } +} + func TestManifestIndexKeepsMarkupReadable(t *testing.T) { t.Parallel() diff --git a/pluginkit/wire/manifest_test.go b/pluginkit/wire/manifest_test.go index bffcd9f..8103cd8 100644 --- a/pluginkit/wire/manifest_test.go +++ b/pluginkit/wire/manifest_test.go @@ -12,7 +12,7 @@ import ( "testing/fstest" ) -// fullManifest names every key a manifest can hold, its last three only as raw extras. +// fullManifest names every key a manifest can hold, its last one only as a raw extra. const fullManifest = `{ "id": "events", "name": "Events", @@ -33,9 +33,16 @@ const fullManifest = `{ {"key": "tone", "type": "choice", "options": ["calm", "loud"]}, {"key": "venue", "type": "picker", "of": "venue"} ], - "graphql": true, - "menu": [{"label": "Reservations"}], - "screens": "@myapp/plugin-events/screens" + "menu": [ + { + "label": "Reservations", "screen": "reservations", "parent": "content", "position": 30, + "capability": "events.manage", "icon": "calendar" + }, + {"label": "Check-in", "screen": "check-in", "capability": "events.checkin"} + ], + "screens": "@myapp/plugin-events/screens", + "screen_capabilities": {"seats": "events.manage"}, + "graphql": true }` // number returns a pointer to value, as a setting's limits hold it. @@ -43,6 +50,11 @@ func number(value float64) *float64 { return &value } +// position returns a pointer to value, as a menu entry's position holds it. +func position(value int) *int { + return &value +} + // fullManifestRead is what fullManifest reads as. func fullManifestRead() Manifest { return Manifest{ @@ -71,11 +83,16 @@ func fullManifestRead() Manifest { {Key: "tone", Type: "choice", Options: []string{"calm", "loud"}}, {Key: "venue", Type: "picker", Of: "venue"}, }, - Extra: map[string]json.RawMessage{ - "graphql": json.RawMessage(`true`), - "menu": json.RawMessage(`[{"label": "Reservations"}]`), - "screens": json.RawMessage(`"@myapp/plugin-events/screens"`), + Menu: []MenuEntry{ + { + Label: "Reservations", Screen: "reservations", Parent: "content", Position: position(30), + Capability: "events.manage", Icon: "calendar", + }, + {Label: "Check-in", Screen: "check-in", Capability: "events.checkin"}, }, + Screens: "@myapp/plugin-events/screens", + ScreenCapabilities: map[string]string{"seats": "events.manage"}, + Extra: map[string]json.RawMessage{"graphql": json.RawMessage(`true`)}, } } @@ -236,6 +253,39 @@ func TestLoadKeepsAZeroLimitApartFromAnAbsentOne(t *testing.T) { assertSame(t, got.Settings, []Setting{{Key: "seats", Type: "count", Min: number(0)}}) } +func TestLoadKeepsTwoMenuEntriesThatAskOneCapabilityForOneScreen(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"menu": [{"label": "a", "screen": "list", "capability": "b"}, `+ + `{"label": "c", "screen": "list", "parent": "content", "capability": "b"}]`) + + assertSame(t, got.Menu, []MenuEntry{ + {Label: "a", Screen: "list", Capability: "b"}, + {Label: "c", Screen: "list", Parent: "content", Capability: "b"}, + }) +} + +func TestLoadReadsANullInsideAMenuEntryAsAnAbsentKey(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"menu": [{"label": "a", "screen": "b", "capability": "c", "parent": null, "position": null, `+ + `"icon": null}]`) + + assertSame(t, got.Menu, []MenuEntry{{Label: "a", Screen: "b", Capability: "c"}}) +} + +func TestLoadKeepsAZeroPositionApartFromAnAbsentOne(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"menu": [{"label": "a", "screen": "b", "capability": "c", "position": 0}, `+ + `{"label": "d", "screen": "e", "capability": "f"}]`) + + assertSame(t, got.Menu, []MenuEntry{ + {Label: "a", Screen: "b", Capability: "c", Position: position(0)}, + {Label: "d", Screen: "e", Capability: "f"}, + }) +} + func TestLoadKeepsAnUnknownKeyInExtra(t *testing.T) { t.Parallel() @@ -252,7 +302,7 @@ func TestLoadReadsANullAsAnAbsentKey(t *testing.T) { t.Parallel() got := feedWith(t, `"version": null, "requires": null, "engines": null, "uninstall": null, "hooks": null, `+ - `"defines": null, "jobs": null, "settings": null`) + `"defines": null, "jobs": null, "settings": null, "menu": null, "screens": null, "screen_capabilities": null`) want, err := loadOne(t, "feed", feedManifest) assertErrorFree(t, err) @@ -271,11 +321,13 @@ func TestLoadDropsAKeyAnEntryHasNoFieldFor(t *testing.T) { t.Parallel() got := feedWith(t, `"defines": [{"hook": "a", "note": 1}], "jobs": [{"name": "b", "every": "1h", "note": 1}], `+ - `"settings": [{"key": "c", "type": "d", "note": 1}]`) + `"settings": [{"key": "c", "type": "d", "note": 1}], "menu": [{"label": "e", "screen": "f", "capability": "g", `+ + `"note": 1}]`) assertSame(t, got.Defines, []Define{{Hook: "a"}}) assertSame(t, got.Jobs, []Job{{Name: "b", Every: "1h"}}) assertSame(t, got.Settings, []Setting{{Key: "c", Type: "d"}}) + assertSame(t, got.Menu, []MenuEntry{{Label: "e", Screen: "f", Capability: "g"}}) assertSame(t, got.Extra, map[string]json.RawMessage(nil)) } @@ -507,10 +559,126 @@ func emptyValues() map[string]wrongShape { } } +// wrongScreenTypes lists menu and screen keys of the wrong JSON type, and what the decoder says of each. +func wrongScreenTypes() map[string]wrongShape { + return map[string]wrongShape{ + "an object for menu": { + `"menu": {"label": "a"}`, + "json: cannot unmarshal object into Go struct field manifest.menu of type []wire.MenuEntry", + }, + "a number for a menu label": { + `"menu": [{"label": 5, "screen": "a", "capability": "b"}]`, + "json: cannot unmarshal number into Go struct field manifest.menu.0.label of type string", + }, + "a number for a menu parent": { + `"menu": [{"label": "a", "screen": "b", "capability": "c", "parent": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.menu.0.parent of type string", + }, + "a string for a menu position": { + `"menu": [{"label": "a", "screen": "b", "capability": "c", "position": "first"}]`, + "json: cannot unmarshal string into Go struct field manifest.menu.0.position of type int", + }, + "a fraction for a menu position": { + `"menu": [{"label": "a", "screen": "b", "capability": "c", "position": 1.5}]`, + "json: cannot unmarshal number 1.5 into Go struct field manifest.menu.0.position of type int", + }, + "a number for a menu icon": { + `"menu": [{"label": "a", "screen": "b", "capability": "c", "icon": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.menu.0.icon of type string", + }, + "a list for screens": { + `"screens": ["a"]`, "json: cannot unmarshal array into Go struct field manifest.screens of type string", + }, + "a list for screen capabilities": { + `"screen_capabilities": ["seats"]`, + "json: cannot unmarshal array into Go struct field manifest.screen_capabilities of type map[string]string", + }, + "a number for a screen capability": { + `"screen_capabilities": {"seats": 3}`, + "json: cannot unmarshal number into Go struct field manifest.screen_capabilities.seats of type string", + }, + } +} + +// emptyScreenValues lists menu entries and screen capabilities wire refuses, and the text it gives for each. +func emptyScreenValues() map[string]wrongShape { + return map[string]wrongShape{ + "a menu entry with no label": {`"menu": [{"screen": "a", "capability": "b"}]`, "menu[0]: label is required"}, + "a menu entry with no screen": {`"menu": [{"label": "a", "capability": "b"}]`, "menu[0]: screen is required"}, + "a null in menu": {`"menu": [null]`, "menu[0]: label is required"}, + "an empty menu before its screen": {`"menu": [{"label": "", "screen": ""}]`, "menu[0]: label is required"}, + "a menu entry with no capability": { + `"menu": [{"label": "a", "screen": "b", "capability": "c"}, {"label": "d", "screen": "e"}]`, + "menu[1]: capability is required", + }, + "a menu screen with a slash": { + `"menu": [{"label": "a", "screen": "list/all", "capability": "b"}]`, + `menu[0]: the screen "list/all" must be one path segment`, + }, + "a menu screen that is only a slash": { + `"menu": [{"label": "a", "screen": "/", "capability": "b"}]`, `menu[0]: the screen "/" must be one path segment`, + }, + "a menu screen with a slash and no capability": { + `"menu": [{"label": "a", "screen": "list/all"}]`, "menu[0]: capability is required", + }, + "a null label in a menu entry": { + `"menu": [{"label": null, "screen": "a", "capability": "b"}]`, "menu[0]: label is required", + }, + "two capabilities for one menu screen": { + `"menu": [{"label": "a", "screen": "list", "capability": "b"}, {"label": "c", "screen": "list", ` + + `"capability": "d"}]`, + `menu[1]: the screen "list" asks for "d" but menu[0] asks for "b"`, + }, + "a later capability for one menu screen": { + `"menu": [{"label": "a", "screen": "list", "capability": "b"}, {"label": "c", "screen": "list", ` + + `"capability": "b"}, {"label": "e", "screen": "list", "capability": "f"}]`, + `menu[2]: the screen "list" asks for "f" but menu[0] asks for "b"`, + }, + "an incomplete entry for a menu screen asked before": { + `"menu": [{"label": "a", "screen": "list", "capability": "b"}, {"label": "c", "screen": "list"}]`, + "menu[1]: capability is required", + }, + "a screen capability that is empty": { + `"screen_capabilities": {"seats": ""}`, `screen_capabilities: the screen "seats" needs a capability`, + }, + "a screen capability that is null": { + `"screen_capabilities": {"seats": null}`, `screen_capabilities: the screen "seats" needs a capability`, + }, + "an empty screen name": { + `"screen_capabilities": {"": "a"}`, `screen_capabilities: the screen "" must be one path segment`, + }, + "a screen name with a slash": { + `"screen_capabilities": {"seats/all": "a"}`, `screen_capabilities: the screen "seats/all" must be one path segment`, + }, + "a screen name with a slash and no capability": { + `"screen_capabilities": {"seats/all": ""}`, + `screen_capabilities: the screen "seats/all" must be one path segment`, + }, + "the first wrong screen in name order": { + `"screen_capabilities": {"b/c": "d", "a": ""}`, `screen_capabilities: the screen "a" needs a capability`, + }, + "a screen capability for a screen of the menu": { + `"menu": [{"label": "a", "screen": "list", "capability": "b"}, {"label": "c", "screen": "seats", ` + + `"capability": "d"}], "screen_capabilities": {"seats": "e"}`, + `screen_capabilities: the screen "seats" takes the capability of menu[1]`, + }, + "a screen capability for the first screen of the menu": { + `"menu": [{"label": "a", "screen": "list", "capability": "b"}], "screen_capabilities": {"list": "c"}`, + `screen_capabilities: the screen "list" takes the capability of menu[0]`, + }, + "an empty setting before a menu entry": {`"settings": [{}], "menu": [{}]`, "settings[0]: key is required"}, + "an empty menu entry before a screen capability": { + `"menu": [{}], "screen_capabilities": {"": "a"}`, "menu[0]: label is required", + }, + } +} + // eachWrongShape runs check in a parallel subtest for every wrong shape. func eachWrongShape(t *testing.T, check func(t *testing.T, tc wrongShape)) { t.Helper() - for _, shapes := range []map[string]wrongShape{wrongTypes(), wrongEntryTypes(), wrongDefines(), emptyValues()} { + for _, shapes := range []map[string]wrongShape{ + wrongTypes(), wrongEntryTypes(), wrongDefines(), emptyValues(), wrongScreenTypes(), emptyScreenValues(), + } { for name, tc := range shapes { t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/pluginkit/wire/packagejson.go b/pluginkit/wire/packagejson.go index 31a0242..da1dd86 100644 --- a/pluginkit/wire/packagejson.go +++ b/pluginkit/wire/packagejson.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "os" + "regexp" "slices" "strings" ) @@ -35,23 +36,50 @@ func (m member) name() string { return m.key.String() } -// frontendPackages returns the sorted unique frontend package names of manifests. -func frontendPackages(manifests []manifest) []string { +// packageName matches a package name, scoped or not, that starts with a letter or a digit. +var packageName = regexp.MustCompile(`^(@[a-zA-Z0-9][a-zA-Z0-9._~-]*/)?[a-zA-Z0-9][a-zA-Z0-9._~-]*$`) + +// screensPackage returns the package the screens module of m names, with its scope, and whether it names one. +func (m manifest) screensPackage() (string, bool) { + segments := strings.SplitN(m.Screens, "/", 3) + name := segments[0] + if strings.HasPrefix(name, "@") && len(segments) > 1 { + name += "/" + segments[1] + } + return name, packageName.MatchString(name) +} + +// pluginPackages returns the sorted unique packages of the frontends and screens modules of manifests. +func pluginPackages(manifests []manifest) ([]string, error) { names := make([]string, 0, len(manifests)) - for _, m := range withFrontend(manifests) { - names = append(names, m.Frontend) + for _, m := range manifests { + if m.Frontend != "" { + names = append(names, m.Frontend) + } + if m.Screens == "" { + continue + } + name, ok := m.screensPackage() + if !ok { + return nil, fmt.Errorf("%s: the screens module %q names no package", m.ID, m.Screens) + } + names = append(names, name) } slices.Sort(names) - return slices.Compact(names) + return slices.Compact(names), nil } -// syncedPackageJSON reads the package.json at path and syncs it to the frontends of manifests. +// syncedPackageJSON reads the package.json at path and syncs it to the frontend and screens packages of manifests. func syncedPackageJSON(path string, manifests []manifest) ([]byte, error) { + want, err := pluginPackages(manifests) + if err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("pluginwire: %s: %w", path, err) } - synced, err := syncPackageJSON(data, frontendPackages(manifests)) + synced, err := syncPackageJSON(data, want) if err != nil { return nil, fmt.Errorf("pluginwire: %s: %w", path, err) } diff --git a/pluginkit/wire/packagejson_test.go b/pluginkit/wire/packagejson_test.go index 725a5a4..c6ec50d 100644 --- a/pluginkit/wire/packagejson_test.go +++ b/pluginkit/wire/packagejson_test.go @@ -3,6 +3,7 @@ package wire import ( + "fmt" "slices" "strings" "testing" @@ -163,6 +164,7 @@ func TestSyncPackageJSONRefusesMalformedInput(t *testing.T) { }{ "empty file": {input: ``, want: "the top level is empty"}, "only whitespace": {input: " \r\n\t", want: "the top level is empty"}, + "bad first character": {input: `x`, want: "invalid character 'x'"}, "bad syntax": {input: `{"name": }`, want: "invalid character '}'"}, "unclosed object": {input: `{"name": "a"`, want: "unexpected EOF"}, "trailing comma": {input: `{"name": "a",}`, want: "invalid character ','"}, @@ -468,18 +470,75 @@ func TestSyncPackageJSONAddsDependenciesLastWhenMissing(t *testing.T) { } } -func TestFrontendPackagesSortsEveryNameOnce(t *testing.T) { +func TestPluginPackagesSortsEveryNameOnce(t *testing.T) { t.Parallel() - got := frontendPackages([]manifest{ + got, err := pluginPackages([]manifest{ {ID: "gallery", Name: "Gallery", Frontend: "@myapp/plugin-gallery"}, {ID: "headless", Name: "Headless", Backend: "example.com/headless"}, {ID: "audit", Name: "Audit", Frontend: "@myapp/plugin-audit"}, {ID: "audit-copy", Name: "Audit copy", Frontend: "@myapp/plugin-audit"}, + {ID: "events", Name: "Events", Backend: "example.com/events", Screens: "@myapp/plugin-events/screens"}, + {ID: "slides", Name: "Slides", Frontend: "@myapp/plugin-gallery", Screens: "@myapp/plugin-gallery/screens"}, }) - want := []string{"@myapp/plugin-audit", "@myapp/plugin-gallery"} + assertErrorFree(t, err) + want := []string{"@myapp/plugin-audit", "@myapp/plugin-events", "@myapp/plugin-gallery"} if !slices.Equal(got, want) { - t.Errorf("frontendPackages() = %q, want %q", got, want) + t.Errorf("pluginPackages() = %q, want %q", got, want) + } +} + +func TestPluginPackagesCutsThePackageOutOfAScreensModule(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "@myapp/plugin-events/screens": "@myapp/plugin-events", + "@myapp/plugin-events/admin/screens": "@myapp/plugin-events", + "@myapp/plugin-events": "@myapp/plugin-events", + "plugin-events/screens": "plugin-events", + "plugin-events": "plugin-events", + "@My-App/Plugin.Events_2~x/screens": "@My-App/Plugin.Events_2~x", + } + for address, want := range tests { + t.Run(address, func(t *testing.T) { + t.Parallel() + + got, err := pluginPackages([]manifest{{ID: "events", Name: "Events", Backend: "a", Screens: address}}) + + assertErrorFree(t, err) + assertSame(t, got, []string{want}) + }) + } +} + +func TestPluginPackagesKeepsAFrontendWhole(t *testing.T) { + t.Parallel() + + got, err := pluginPackages([]manifest{{ID: "events", Name: "Events", Frontend: "@myapp/plugin-events/admin"}}) + + assertErrorFree(t, err) + assertSame(t, got, []string{"@myapp/plugin-events/admin"}) +} + +func TestPluginPackagesRefusesAScreensModuleThatNamesNoPackage(t *testing.T) { + t.Parallel() + + addresses := []string{ + "./screens", "../screens", "/admin/screens/events.js", "@myapp", "@myapp/", "@myapp//screens", "@/screens", + "https://example.com/events.js", "node:fs", ".hidden/screens", "_private/screens", "-dash/screens", + } + for _, address := range addresses { + t.Run(address, func(t *testing.T) { + t.Parallel() + + got, err := pluginPackages([]manifest{ + {ID: "audit", Name: "Audit", Frontend: "@myapp/plugin-audit"}, + {ID: "events", Name: "Events", Backend: "a", Screens: address}, + }) + + assertErrorText(t, err, fmt.Sprintf("events: the screens module %q names no package", address)) + assertSame(t, got, []string(nil)) + }) } } diff --git a/pluginkit/wire/screens_test.go b/pluginkit/wire/screens_test.go new file mode 100644 index 0000000..536208f --- /dev/null +++ b/pluginkit/wire/screens_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// screensPath is where these tests ask for the lazy screens map. +const screensPath = "frontend/src/plugins/screens.ts" + +// withScreens returns cfg with the lazy screens map switched on. +func withScreens(cfg Config) Config { + cfg.TSScreensPath = screensPath + return cfg +} + +func TestGenerateScreensMapsEachPluginInWiringOrder(t *testing.T) { + t.Parallel() + + got := string(generateScreens(testConfig, []manifest{ + {ID: "gallery", Name: "Gallery", Frontend: "@myapp/plugin-gallery", Screens: "@myapp/plugin-gallery/screens"}, + {ID: "billing", Name: "Billing", Backend: "example.com/billing"}, + {ID: "events", Name: "Events", Backend: "example.com/events", Screens: "@myapp/plugin-events/screens"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +export const screens: Readonly Promise>> = { + "gallery": () => import("@myapp/plugin-gallery/screens"), + "events": () => import("@myapp/plugin-events/screens"), +} +` + if got != want { + t.Errorf("generateScreens() = %q, want %q", got, want) + } +} + +func TestGenerateScreensWritesAnEmptyMapWhenNoPluginNamesScreens(t *testing.T) { + t.Parallel() + + got := string(generateScreens(testConfig, []manifest{ + {ID: "gallery", Name: "Gallery", Frontend: "@myapp/plugin-gallery"}, + })) + + want := `// SPDX-License-Identifier: Apache-2.0 + +// Code generated by pluginwire. DO NOT EDIT. + +export const screens: Readonly Promise>> = {} +` + if got != want { + t.Errorf("generateScreens() = %q, want %q", got, want) + } +} + +func TestGenerateScreensUsesTSLicenseWhenSet(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.TSLicense = "AGPL-3.0-or-later" + + got := string(generateScreens(cfg, nil)) + + if want := "// SPDX-License-Identifier: AGPL-3.0-or-later\n"; got[:len(want)] != want { + t.Errorf("generateScreens() header = %q, want the TSLicense field %q", got[:len(want)], want) + } +} + +func TestGenerateScreensWritesEachAddressAsAStringLiteral(t *testing.T) { + t.Parallel() + + got := string(generateScreens(testConfig, []manifest{ + {ID: "events", Name: "Events", Backend: "a", Screens: "./a'b\"c\\d\ne\U00002028f&"}, + })) + + want := "\t\"events\": () => import(\"./a'b\\\"c\\\\d\\ne\\u2028f&\"),\n" + if !strings.Contains(got, want) { + t.Errorf("generateScreens() = %q, want it to hold %q", got, want) + } +} + +func TestRunWritesTheScreensMapBesideEveryOtherOutput(t *testing.T) { + t.Parallel() + + feed := withKeys(feedManifest, `"screens": "@myapp/plugin-feed/screens"`) + for name, cfg := range everySwitchToday() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + on := withScreens(cfg) + root := wiringTree(t, on, map[string]string{"feed": feed}) + + if err := Run(root, on); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + files := generatedFiles(t, root) + want := "\t\"feed\": () => import(\"@myapp/plugin-feed/screens\"),\n" + if !strings.Contains(files[screensPath], want) { + t.Errorf("%s = %q, want it to hold %q", screensPath, files[screensPath], want) + } + delete(files, screensPath) + assertSameFiles(t, files, generate(t, cfg, map[string]string{"feed": feed})) + }) + } +} + +func TestRunWritesTheScreensPackageIntoThePackageJSON(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.PackageJSONPath = "frontend/package.json" + + files := generate(t, cfg, map[string]string{ + "events": `{"id": "events", "name": "Events", "backend": "example.com/myapp/plugins/events", ` + + `"screens": "@myapp/plugin-events/screens"}`, + }) + + want := `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-events": "workspace:*" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-events" + ] + } +} +` + if got := files[cfg.PackageJSONPath]; got != want { + t.Errorf("package.json = %q, want %q", got, want) + } +} + +func TestRunRefusesAScreensModuleThatNamesNoPackageBeforeAnyWrite(t *testing.T) { + t.Parallel() + + cfg := withIndex(withScreens(testConfig)) + cfg.PackageJSONPath = "frontend/package.json" + root := writeBeside(t, cfg, "beta", + `{"id": "beta", "name": "Beta", "backend": "example.com/myapp/plugins/beta", "screens": "./screens"}`) + writeFile(t, root, cfg.PackageJSONPath, appPackageJSON) + makeDirs(t, root, "internal/plugins") + + err := Run(root, cfg) + + assertErrorText(t, err, "pluginwire: "+filepath.Join(root, "frontend", "package.json")+ + `: beta: the screens module "./screens" names no package`) + assertWiringKept(t, cfg, root) + if kept := readFile(t, root, cfg.PackageJSONPath); kept != appPackageJSON { + t.Errorf("package.json after the refusal = %q, want it untouched", kept) + } + assertNoIndex(t, root) + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(screensPath))); !os.IsNotExist(err) { + t.Errorf("the screens map exists after the refusal, stat error = %v", err) + } +} + +func TestRunTakesAScreensModuleThatNamesNoPackageWithoutAPackageJSON(t *testing.T) { + t.Parallel() + + files := generate(t, withScreens(testConfig), map[string]string{ + "beta": `{"id": "beta", "name": "Beta", "backend": "example.com/myapp/plugins/beta", "screens": "./screens"}`, + }) + + if want := "\t\"beta\": () => import(\"./screens\"),\n"; !strings.Contains(files[screensPath], want) { + t.Errorf("%s = %q, want it to hold %q", screensPath, files[screensPath], want) + } +} diff --git a/pluginkit/wire/wire.go b/pluginkit/wire/wire.go index ffdf652..6b4b6f7 100644 --- a/pluginkit/wire/wire.go +++ b/pluginkit/wire/wire.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// Package wire reads plugin manifests and generates wiring, plus an optional registry, index and package.json update. +// Package wire reads plugin manifests and writes wiring, plus optional registry, screens map, index and package.json. package wire import ( @@ -27,9 +27,12 @@ type Config struct { GoRegistryPath string GoRegistryPackage string - // PackageJSONPath is the optional slash path of a package.json that depends on each frontend, listed in pluginwire. + // PackageJSONPath is the optional slash path of a package.json depending on each plugin package, listed in pluginwire. PackageJSONPath string + // TSScreensPath is the optional slash path of a generated TypeScript map from each plugin id to its screens import. + TSScreensPath string + // ManifestIndexPath is the optional slash path of a generated JSON list of every manifest, in wiring order. ManifestIndexPath string @@ -262,6 +265,28 @@ func generateTS(cfg Config, manifests []manifest) []byte { return []byte(b.String()) } +// jsString returns s as a double-quoted JavaScript string literal, its markup characters left readable. +func jsString(s string) string { + quoted, _ := encodeJSON(s, "") + return strings.TrimSuffix(string(quoted), "\n") +} + +// generateScreens renders the generated TypeScript map from each plugin id to a lazy import of its screens module. +func generateScreens(cfg Config, manifests []manifest) []byte { + var lines strings.Builder + for _, m := range manifests { + if m.Screens != "" { + fmt.Fprintf(&lines, "\t%s: () => import(%s),\n", jsString(m.ID), jsString(m.Screens)) + } + } + body := "{}" + if lines.Len() > 0 { + body = "{\n" + lines.String() + "}" + } + return []byte(generatedHeader(cfg.tsLicense()) + + "export const screens: Readonly Promise>> = " + body + "\n") +} + // output is one file Run writes, its slash path under the root and its contents. type output struct { path string @@ -277,6 +302,9 @@ func outputs(root string, cfg Config, manifests []manifest) ([]output, error) { if cfg.GoRegistryPath != "" { files = append(files, output{path: cfg.GoRegistryPath, data: generateRegistry(cfg, manifests)}) } + if cfg.TSScreensPath != "" { + files = append(files, output{path: cfg.TSScreensPath, data: generateScreens(cfg, manifests)}) + } if cfg.ManifestIndexPath != "" { index, err := generateIndex(manifests) if err != nil { @@ -294,7 +322,7 @@ func outputs(root string, cfg Config, manifests []manifest) ([]output, error) { return append(files, output{path: cfg.PackageJSONPath, data: packageJSON}), nil } -// Run writes the Go and TypeScript wiring and any optional registry, index and package.json from the manifests in root. +// Run writes the Go and TypeScript wiring and any optional registry, screens map, index and package.json from root. func Run(root string, cfg Config) error { if err := validateConfig(cfg); err != nil { return err