diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 1f75f66..dab3a9a 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` holds one `plugin.json` as data, reading `version`, `license`, `did`, `requires`, `tested`, `engines`, `uninstall`, `hooks`, `defines`, `jobs` and `settings`, with every other key kept raw in `Extra`. +- `wire.Read` reads one plugin from its own file system, and `wire.Load` reads every plugin under the roots with the checks `wire.Run` applies. +- `wire.Config` gains an optional `ManifestIndexPath`, a JSON list of every manifest in wiring order that reads back into `[]wire.Manifest`. +- `wire.Run` refuses a manifest whose new keys have the wrong shape. A manifest with only today's keys runs as before, with the same bytes and error texts. + ## 0.8.0 - 2026-11-04 - The module needs Go 1.27.3. diff --git a/pluginkit/wire/manifest.go b/pluginkit/wire/manifest.go new file mode 100644 index 0000000..94d116d --- /dev/null +++ b/pluginkit/wire/manifest.go @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "io/fs" + "maps" + "slices" + "strings" +) + +// Manifest is one plugin.json read as data, every key after Frontend optional. +type Manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Backend string `json:"backend,omitempty"` + Frontend string `json:"frontend,omitempty"` + + Version string `json:"version,omitempty"` + License string `json:"license,omitempty"` + DID string `json:"did,omitempty"` + Requires map[string]string `json:"requires,omitempty"` + Tested string `json:"tested,omitempty"` + Engines []string `json:"engines,omitempty"` + Uninstall bool `json:"uninstall,omitempty"` + Hooks []string `json:"hooks,omitempty"` + Defines []Define `json:"defines,omitempty"` + Jobs []Job `json:"jobs,omitempty"` + Settings []Setting `json:"settings,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:"-"` +} + +// MarshalJSON writes m under the key names of plugin.json, its Extra keys after the typed ones. +func (m Manifest) MarshalJSON() ([]byte, error) { + typed, typedErr := encodeJSON(manifest(m), "") + extra, extraErr := encodeJSON(m.Extra, "") + if err := errors.Join(typedErr, extraErr); err != nil { + return nil, err + } + if len(m.Extra) == 0 { + return typed, nil + } + return slices.Concat(typed[:len(typed)-len("}\n")], []byte(","), extra[1:]), nil +} + +// UnmarshalJSON reads m from a plugin.json or an entry of the manifest index, checking only the JSON types. +func (m *Manifest) UnmarshalJSON(data []byte) error { + parsed, err := parseManifest(data) + *m = Manifest(parsed) + return err +} + +// Define is one hook a plugin offers to other plugins. +type Define struct { + Hook string `json:"hook"` + Kind string `json:"kind,omitempty"` + Fires string `json:"fires,omitempty"` +} + +// UnmarshalJSON replaces d with a plain string read as the name of the hook, or an object read by its keys. +func (d *Define) UnmarshalJSON(data []byte) error { + var hook string + if json.Unmarshal(data, &hook) == nil { + *d = Define{Hook: hook} + return nil + } + type define Define + var entry define + if err := json.Unmarshal(data, &entry); err != nil { + return err + } + *d = Define(entry) + return nil +} + +// Job is one scheduled job of a plugin and its default interval. +type Job struct { + Name string `json:"name"` + Every string `json:"every"` +} + +// Setting is one setting of a plugin, the schema an application draws a form from. +type Setting struct { + Key string `json:"key"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + Label string `json:"label,omitempty"` + Help string `json:"help,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Options []string `json:"options,omitempty"` + Of string `json:"of,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", +} + +// isTypedKey reports whether encoding/json reads key into a field of Manifest. +func isTypedKey(key string, _ json.RawMessage) bool { + return slices.ContainsFunc(typedKeys, func(typed string) bool { return strings.EqualFold(key, typed) }) +} + +// parseManifest decodes one plugin.json, keeping the keys Manifest has no field for in Extra. +func parseManifest(data []byte) (manifest, error) { + var m manifest + var raw map[string]json.RawMessage + err := json.Unmarshal(data, &m) + if err == nil { + err = json.Unmarshal(data, &raw) + } + if err != nil { + return manifest{}, nameDefine(data, err) + } + maps.DeleteFunc(raw, isTypedKey) + if len(raw) > 0 { + m.Extra = raw + } + return m, nil +} + +// nameDefine puts the entry of defines that gave err in front of it, as the decoder does for jobs and settings. +func nameDefine(data []byte, err error) error { + var entries struct{ Defines []json.RawMessage } + if json.Unmarshal(data, &entries) != nil { + return err + } + for i, entry := range entries.Defines { + if entryErr := new(Define).UnmarshalJSON(entry); entryErr != nil && entryErr.Error() == err.Error() { + return fmt.Errorf("defines[%d]: %w", i, err) + } + } + return err +} + +// checkList refuses an empty string in the list called name. +func checkList(name string, values []string) error { + if i := slices.Index(values, ""); i >= 0 { + return fmt.Errorf("%s[%d]: must not be empty", name, i) + } + return nil +} + +// checkDefines refuses a defined hook with no name. +func checkDefines(defines []Define) error { + for i, define := range defines { + if define.Hook == "" { + return fmt.Errorf("defines[%d]: hook is required", i) + } + } + return nil +} + +// checkJobs refuses a job with no name or no interval. +func checkJobs(jobs []Job) error { + for i, job := range jobs { + if job.Name == "" { + return fmt.Errorf("jobs[%d]: name is required", i) + } + if job.Every == "" { + return fmt.Errorf("jobs[%d]: every is required", i) + } + } + return nil +} + +// checkSettings refuses a setting with no key, no type or an empty option. +func checkSettings(settings []Setting) error { + for i, setting := range settings { + if setting.Key == "" { + return fmt.Errorf("settings[%d]: key is required", i) + } + if setting.Type == "" { + return fmt.Errorf("settings[%d]: type is required", i) + } + if err := checkList(fmt.Sprintf("settings[%d].options", i), setting.Options); err != nil { + return err + } + } + 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( + checkList("engines", m.Engines), + checkList("hooks", m.Hooks), + checkDefines(m.Defines), + checkJobs(m.Jobs), + checkSettings(m.Settings), + ) +} + +// Read reads one plugin's manifest from the plugin's own file system, leaving the folder match and refused ids to Load. +func Read(plugin fs.FS) (Manifest, error) { + data, err := fs.ReadFile(plugin, "plugin.json") + if err != nil { + return Manifest{}, err + } + m, err := parseManifest(data) + if err != nil { + return Manifest{}, err + } + if err := validate(m, m.ID); err != nil { + return Manifest{}, err + } + return Manifest(m), nil +} + +// Load reads every manifest under the roots of dir in order, with the checks Run applies. +func Load(dir string, roots, reserved []string) ([]Manifest, error) { + if len(roots) == 0 { + return nil, errors.New("wire: Load needs at least one root") + } + manifests, err := loadRoots(dir, roots, reserved) + if err != nil { + return nil, err + } + return exported(manifests), nil +} + +// exported returns manifests as the type callers read. +func exported(manifests []manifest) []Manifest { + converted := make([]Manifest, len(manifests)) + for i, m := range manifests { + converted[i] = Manifest(m) + } + return converted +} + +// generateIndex renders every manifest as a JSON list in wiring order. +func generateIndex(manifests []manifest) ([]byte, error) { + return encodeJSON(exported(manifests), "\t") +} + +// encodeJSON returns value as JSON ending in a newline, indented by indent, with its HTML characters left readable. +func encodeJSON(value any, indent string) ([]byte, error) { + var b bytes.Buffer + encoder := json.NewEncoder(&b) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", indent) + err := encoder.Encode(value) + return b.Bytes(), err +} diff --git a/pluginkit/wire/manifest_identity_test.go b/pluginkit/wire/manifest_identity_test.go new file mode 100644 index 0000000..40608e4 --- /dev/null +++ b/pluginkit/wire/manifest_identity_test.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// everyNewKey is every key a manifest may name beside the ones wire has always read. +const everyNewKey = `"version": "2.0.0", + "license": "Apache-2.0", + "did": "did:plc:example", + "requires": {"app": ">=0.31.0"}, + "tested": "0.32", + "engines": ["postgres", "sqlite"], + "uninstall": true, + "hooks": ["content.changed"], + "defines": ["shop.order-placed", {"hook": "forum.post-check", "kind": "filter", "fires": "job"}], + "jobs": [{"name": "forget-old", "every": "24h"}], + "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", + "pages": [{"word": "forums"}], + "islands": ["reply-form"], + "blocks": ["blocks/latest-topics"], + "assets": {"style": "public/forum.css"}, + "mail_addresses": true` + +// appPackageJSON is the package.json of an application before the generator runs. +const appPackageJSON = `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*" + } +} +` + +// withKeys returns manifestJSON with keys added before its closing brace. +func withKeys(manifestJSON, keys string) string { + return strings.TrimSuffix(manifestJSON, "}") + ",\n\t" + keys + "\n}" +} + +// wiringTree writes manifests by plugin folder, and every directory and package.json cfg needs, under a new root. +func wiringTree(t *testing.T, cfg Config, manifests map[string]string) string { + t.Helper() + root := t.TempDir() + for dir, manifestJSON := range manifests { + writePlugin(t, root, dir, manifestJSON) + } + makeDirs(t, root, "cmd/myapp", "frontend/src/plugins", "internal/graphroot") + if cfg.PackageJSONPath != "" { + writeFile(t, root, cfg.PackageJSONPath, appPackageJSON) + } + return root +} + +// generatedFiles returns every file under root outside the plugin folders, by slash path. +func generatedFiles(t *testing.T, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + if entry.IsDir() { + if relative == "plugins" { + return fs.SkipDir + } + return nil + } + data, err := os.ReadFile(path) + files[filepath.ToSlash(relative)] = string(data) + return err + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + return files +} + +// generate runs the generator over manifests and returns every file it left outside the plugin folders. +func generate(t *testing.T, cfg Config, manifests map[string]string) map[string]string { + t.Helper() + root := wiringTree(t, cfg, manifests) + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + return generatedFiles(t, root) +} + +// assertSameFiles checks that two generated trees hold the same files with the same bytes. +func assertSameFiles(t *testing.T, got, want map[string]string) { + t.Helper() + if !maps.Equal(got, want) { + t.Errorf("generated files = %q, want %q", got, want) + } +} + +// everySwitchToday lists the configs that switch on each output wire has always had. +func everySwitchToday() map[string]Config { + withPackageJSON := testConfig + withPackageJSON.PackageJSONPath = "frontend/package.json" + withRegistry := testConfig + withRegistry.GoRegistryPath = "internal/graphroot/registry_gen.go" + withRegistry.GoRegistryPackage = "graphroot" + withBoth := withRegistry + withBoth.PackageJSONPath = withPackageJSON.PackageJSONPath + return map[string]Config{ + "only the wiring": testConfig, + "a package.json": withPackageJSON, + "a registry": withRegistry, + "both": withBoth, + } +} + +func TestRunKeepsTheWiringBytesWhenManifestsGainNewFields(t *testing.T) { + t.Parallel() + + alphaManifest := `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha"}` + for name, cfg := range everySwitchToday() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + plain := generate(t, cfg, map[string]string{"alpha": alphaManifest, "feed": feedManifest}) + gained := generate(t, cfg, map[string]string{ + "alpha": withKeys(alphaManifest, everyNewKey), + "feed": withKeys(feedManifest, everyNewKey), + }) + + assertSameFiles(t, gained, plain) + }) + } +} + +func TestRunKeepsTheWiringBytesOfAManifestWithAGraphqlKey(t *testing.T) { + t.Parallel() + + plain := map[string]string{} + withGraphql := map[string]string{} + for _, id := range []string{"email", "fields", "importer", "whatsapp"} { + manifestJSON := `{"id": "` + id + `", "name": "A plugin", "backend": "example.com/myapp/plugins/` + id + + `", "frontend": "@myapp/plugin-` + id + `"}` + plain[id] = manifestJSON + withGraphql[id] = withKeys(manifestJSON, `"graphql": true`) + } + for name, cfg := range everySwitchToday() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + assertSameFiles(t, generate(t, cfg, withGraphql), generate(t, cfg, plain)) + }) + } +} + +func TestRunWritesNoNewFileWhenEverySwitchIsOff(t *testing.T) { + t.Parallel() + + files := generate(t, testConfig, map[string]string{"feed": withKeys(feedManifest, everyNewKey)}) + + want := []string{testConfig.GoWiringPath, testConfig.TSWiringPath} + if got := slices.Sorted(maps.Keys(files)); !slices.Equal(got, want) { + t.Errorf("generated files = %q, want only %q", got, want) + } +} + +// todaysErrorCase is one manifest wire refuses, with the text it has always given after the manifest's path. +type todaysErrorCase struct { + dir string + manifest string + want string +} + +// todaysErrors lists the manifests wire refuses and the text each has always given. +func todaysErrors() map[string]todaysErrorCase { + return map[string]todaysErrorCase{ + "malformed json": {dir: "broken", manifest: `{`, want: "unexpected end of JSON input"}, + "a number for the id": { + dir: "numbered", manifest: `{"id": 5}`, + want: "json: cannot unmarshal number into Go struct field manifest.id of type string", + }, + "a number for the backend": { + dir: "numbered", manifest: `{"id": "numbered", "name": "Numbered", "backend": 5}`, + want: "json: cannot unmarshal number into Go struct field manifest.backend of type string", + }, + "a list at the top level": { + dir: "listed", manifest: `[]`, + want: "json: cannot unmarshal array into Go value of type wire.manifest", + }, + "a missing id": { + dir: "anon", manifest: `{"name": "Anon", "backend": "example.com/anon"}`, + want: `id "" must match ^[a-z][a-z0-9-]*$`, + }, + "an uppercase id with no name": { + dir: "Loud", manifest: `{"id": "Loud"}`, + want: `id "Loud" must match ^[a-z][a-z0-9-]*$`, + }, + "an id that does not match its folder, with no name": { + dir: "alias", manifest: `{"id": "other"}`, + want: `id "other" does not match directory "alias"`, + }, + "a missing name with no backend": { + dir: "nameless", manifest: `{"id": "nameless"}`, + want: "name is required", + }, + "neither backend nor frontend": { + dir: "hollow", manifest: `{"id": "hollow", "name": "Hollow"}`, + want: "at least one of backend or frontend is required", + }, + "a bad id beside an empty new key": { + dir: "Loud", manifest: `{"id": "Loud", "name": "Loud", "backend": "example.com/loud", "engines": [""]}`, + want: `id "Loud" must match ^[a-z][a-z0-9-]*$`, + }, + "an id that does not match its folder beside an empty new key": { + dir: "alias", manifest: `{"id": "other", "name": "Other", "backend": "example.com/other", "hooks": [""]}`, + want: `id "other" does not match directory "alias"`, + }, + "a missing name beside an empty new key": { + dir: "nameless", manifest: `{"id": "nameless", "backend": "example.com/nameless", "jobs": [{}]}`, + want: "name is required", + }, + "neither backend nor frontend beside an empty new key": { + dir: "hollow", manifest: `{"id": "hollow", "name": "Hollow", "settings": [{}]}`, + want: "at least one of backend or frontend is required", + }, + } +} + +func TestRunKeepsTodaysErrorTexts(t *testing.T) { + t.Parallel() + + for name, tc := range todaysErrors() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, tc.dir, tc.manifest) + + err := Run(root, testConfig) + + assertErrorText(t, err, "pluginwire: "+manifestPath(root, tc.dir)+": "+tc.want) + }) + } +} + +func TestRunKeepsTodaysErrorTextOfAMissingRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + err := Run(root, testConfig) + + assertErrorText(t, err, + "pluginwire: reading plugins directory: open "+filepath.Join(root, "plugins")+": no such file or directory") +} + +func TestRunKeepsTodaysErrorTextOfAMissingManifest(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "ghost", "") + + err := Run(root, testConfig) + + path := manifestPath(root, "ghost") + assertErrorText(t, err, "pluginwire: "+path+": open "+path+": no such file or directory") +} diff --git a/pluginkit/wire/manifest_index_test.go b/pluginkit/wire/manifest_index_test.go new file mode 100644 index 0000000..121ce04 --- /dev/null +++ b/pluginkit/wire/manifest_index_test.go @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// indexPath is where these tests ask for the manifest index. +const indexPath = "internal/plugins/index.json" + +// withIndex returns cfg with the manifest index switched on. +func withIndex(cfg Config) Config { + cfg.ManifestIndexPath = indexPath + return cfg +} + +// runForIndex runs the generator over manifests with the index on and returns what it wrote there. +func runForIndex(t *testing.T, manifests map[string]string) string { + t.Helper() + root := wiringTree(t, withIndex(testConfig), manifests) + makeDirs(t, root, "internal/plugins") + if err := Run(root, withIndex(testConfig)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + return readFile(t, root, indexPath) +} + +// fullIndex is the index of the plugins fullManifest and feedManifest. +const fullIndex = `[ + { + "id": "events", + "name": "Events", + "backend": "example.com/myapp/plugins/events", + "frontend": "@myapp/plugin-events", + "version": "2.0.0", + "license": "Apache-2.0", + "did": "did:plc:example", + "requires": { + "app": ">=0.31.0", + "shop": "^2.1" + }, + "tested": "0.32", + "engines": [ + "postgres", + "sqlite" + ], + "uninstall": true, + "hooks": [ + "content.changed", + "page.head" + ], + "defines": [ + { + "hook": "shop.order-placed" + }, + { + "hook": "forum.post-check", + "kind": "filter", + "fires": "job" + } + ], + "jobs": [ + { + "name": "forget-old", + "every": "24h" + } + ], + "settings": [ + { + "key": "max_seats", + "type": "count", + "default": "4", + "label": "Seats", + "help": "Per booking", + "min": 1, + "max": 50 + }, + { + "key": "tone", + "type": "choice", + "options": [ + "calm", + "loud" + ] + }, + { + "key": "venue", + "type": "picker", + "of": "venue" + } + ], + "graphql": true, + "menu": [ + { + "label": "Reservations" + } + ], + "screens": "@myapp/plugin-events/screens" + }, + { + "id": "feed", + "name": "Feed", + "backend": "example.com/myapp/plugins/feed", + "frontend": "@myapp/plugin-feed" + } +] +` + +func TestRunWritesTheManifestIndexWhenTheSwitchIsOn(t *testing.T) { + t.Parallel() + + got := runForIndex(t, map[string]string{"events": fullManifest, "feed": feedManifest}) + + if got != fullIndex { + t.Errorf("index = %s\nwant %s", got, fullIndex) + } +} + +func TestRunListsTheIndexInWiringOrderAcrossRoots(t *testing.T) { + t.Parallel() + + cfg := withIndex(testConfig) + cfg.Roots = []string{"plugins", "extra"} + root := wiringTree(t, cfg, map[string]string{ + "mid": `{"id": "mid", "name": "Mid", "frontend": "@myapp/plugin-mid"}`, + "zeta": `{"id": "zeta", "name": "Zeta", "backend": "example.com/zeta"}`, + }) + writePluginIn(t, root, "extra", "alpha", `{"id": "alpha", "name": "Alpha", "backend": "example.com/alpha"}`) + makeDirs(t, root, "internal/plugins") + + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + assertIDs(t, readIndex(t, root), "mid", "zeta", "alpha") +} + +// readIndex reads the manifest index under root back into manifests. +func readIndex(t *testing.T, root string) []Manifest { + t.Helper() + var got []Manifest + if err := json.Unmarshal([]byte(readFile(t, root, indexPath)), &got); err != nil { + t.Fatalf("reading the index back: %v", err) + } + return got +} + +func TestManifestIndexLeavesOutAnAbsentOrEmptyOptionalKey(t *testing.T) { + t.Parallel() + + got := runForIndex(t, map[string]string{ + "feed": withKeys(feedManifest, + `"uninstall": false, "engines": [], "requires": {}, "settings": [{"key": "seats", "type": "count"}]`), + }) + + want := `[ + { + "id": "feed", + "name": "Feed", + "backend": "example.com/myapp/plugins/feed", + "frontend": "@myapp/plugin-feed", + "settings": [ + { + "key": "seats", + "type": "count" + } + ] + } +] +` + if got != want { + t.Errorf("index = %s\nwant %s", got, want) + } +} + +func TestManifestIndexKeepsAZeroLimit(t *testing.T) { + t.Parallel() + + got := runForIndex(t, map[string]string{ + "feed": withKeys(feedManifest, `"settings": [{"key": "seats", "type": "count", "min": 0, "max": 0}]`), + }) + + if !strings.Contains(got, `"min": 0,`) || !strings.Contains(got, `"max": 0`) { + t.Errorf("index = %s, want both limits kept at zero", got) + } +} + +func TestManifestIndexKeepsMarkupReadable(t *testing.T) { + t.Parallel() + + got := runForIndex(t, map[string]string{ + "feed": withKeys(feedManifest, `"requires": {"app": ">=0.31.0 <1.0.0"}, "note": "a & b "`), + }) + + for _, want := range []string{`"app": ">=0.31.0 <1.0.0"`, `"note": "a & b "`} { + if !strings.Contains(got, want) { + t.Errorf("index = %s, want it to hold %s", got, want) + } + } +} + +func TestRunWritesAnEmptyIndexWhenNoPluginExists(t *testing.T) { + t.Parallel() + + root := wiringTree(t, withIndex(testConfig), nil) + makeDirs(t, root, "plugins", "internal/plugins") + + if err := Run(root, withIndex(testConfig)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if got := readFile(t, root, indexPath); got != "[]\n" { + t.Errorf("index = %q, want an empty list", got) + } +} + +func TestRunWritesTheIndexBesideEveryOtherOutput(t *testing.T) { + t.Parallel() + + for name, cfg := range everySwitchToday() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + on := withIndex(cfg) + root := wiringTree(t, on, map[string]string{"feed": feedManifest}) + makeDirs(t, root, "internal/plugins") + + if err := Run(root, on); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + files := generatedFiles(t, root) + if files[indexPath] == "" { + t.Errorf("generated files = %q, want the index among them", files) + } + delete(files, indexPath) + assertSameFiles(t, files, generate(t, cfg, map[string]string{"feed": feedManifest})) + }) + } +} + +func TestRunWritesNoIndexWhenAPackageJSONStopsIt(t *testing.T) { + t.Parallel() + + cfg := withIndex(testConfig) + cfg.PackageJSONPath = "frontend/package.json" + root := writeBeside(t, cfg, "beta", `{"id": "beta", "name": "Beta", "frontend": "@myapp/plugin-beta"}`) + writeFile(t, root, cfg.PackageJSONPath, `{`) + makeDirs(t, root, "internal/plugins") + + if err := Run(root, cfg); err == nil { + t.Fatal("Run() error = nil, want the package.json error") + } + + assertWiringKept(t, cfg, root) + assertNoIndex(t, root) +} + +// assertNoIndex checks that no manifest index exists under root. +func assertNoIndex(t *testing.T, root string) { + t.Helper() + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(indexPath))); !os.IsNotExist(err) { + t.Errorf("the index exists after the failure, stat error = %v", err) + } +} + +// tooDeepForTheIndex returns feedManifest with a key as deep as the reader takes, the index list one level past it. +func tooDeepForTheIndex() string { + return withKeys(feedManifest, `"note": `+strings.Repeat("[", 9999)+strings.Repeat("]", 9999)) +} + +func TestRunRefusesAManifestTooDeepForTheIndex(t *testing.T) { + t.Parallel() + + cfg := withIndex(testConfig) + root := writeBeside(t, cfg, "feed", tooDeepForTheIndex()) + makeDirs(t, root, "internal/plugins") + + err := Run(root, cfg) + + assertErrorText(t, err, + "pluginwire: "+indexPath+": json: error calling MarshalJSON for type *wire.Manifest: exceeded max depth") + assertWiringKept(t, cfg, root) + assertNoIndex(t, root) +} + +func TestRunTakesAManifestTooDeepForTheIndexWhenTheSwitchIsOff(t *testing.T) { + t.Parallel() + + _, err := runBeside(t, testConfig, "feed", tooDeepForTheIndex()) + + assertErrorFree(t, err) +} + +func TestRunReportsAnIndexItCannotWrite(t *testing.T) { + t.Parallel() + + root := wiringTree(t, withIndex(testConfig), map[string]string{"feed": feedManifest}) + + err := Run(root, withIndex(testConfig)) + + path := filepath.Join(root, filepath.FromSlash(indexPath)) + assertErrorText(t, err, "pluginwire: open "+path+": no such file or directory") +} + +func TestManifestIndexReadsBackAsLoadReadsTheManifests(t *testing.T) { + t.Parallel() + + root := wiringTree(t, withIndex(testConfig), map[string]string{ + "events": fullManifest, + "feed": withKeys(feedManifest, `"defines": ["shop.order-placed"]`), + }) + makeDirs(t, root, "internal/plugins") + if err := Run(root, withIndex(testConfig)); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + loaded, err := Load(root, []string{"plugins"}, nil) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + + got := readIndex(t, root) + + if len(got) != len(loaded) { + t.Fatalf("index = %d manifests, want %d", len(got), len(loaded)) + } + for i := range got { + assertSameManifest(t, got[i], loaded[i]) + } +} + +// assertSameManifest checks that got and want match, each raw extra compared by its JSON value. +func assertSameManifest(t *testing.T, got, want Manifest) { + t.Helper() + if len(got.Extra) != len(want.Extra) { + t.Errorf("Extra = %q, want %q", got.Extra, want.Extra) + } + for key, raw := range want.Extra { + var gotValue, wantValue any + if err := json.Unmarshal(got.Extra[key], &gotValue); err != nil { + t.Errorf("Extra[%s] = %q: %v", key, got.Extra[key], err) + } + if err := json.Unmarshal(raw, &wantValue); err != nil { + t.Errorf("want Extra[%s] = %q: %v", key, raw, err) + } + if !reflect.DeepEqual(gotValue, wantValue) { + t.Errorf("Extra[%s] = %v, want %v", key, gotValue, wantValue) + } + } + got.Extra, want.Extra = nil, nil + if !reflect.DeepEqual(got, want) { + t.Errorf("manifest = %+v, want %+v", got, want) + } +} + +func TestManifestMarshalsUnderTheKeysOfItsFile(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + manifest Manifest + want string + }{ + "no extra": { + Manifest{ID: "feed", Name: "Feed"}, + `{"id":"feed","name":"Feed"}`, + }, + "an empty extra": { + Manifest{ID: "feed", Name: "Feed", Extra: map[string]json.RawMessage{}}, + `{"id":"feed","name":"Feed"}`, + }, + "an extra after a typed object": { + Manifest{ + ID: "feed", Name: "Feed", Requires: map[string]string{"app": "1"}, + Extra: map[string]json.RawMessage{"note": json.RawMessage(`1`)}, + }, + `{"id":"feed","name":"Feed","requires":{"app":"1"},"note":1}`, + }, + "two extras in key order": { + Manifest{ + ID: "feed", Name: "Feed", + Extra: map[string]json.RawMessage{"zone": json.RawMessage(`2`), "area": json.RawMessage(`1`)}, + }, + `{"id":"feed","name":"Feed","area":1,"zone":2}`, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := json.Marshal(tc.manifest) + + if err != nil { + t.Fatalf("json.Marshal() error = %v, want nil", err) + } + if string(got) != tc.want { + t.Errorf("json.Marshal() = %s, want %s", got, tc.want) + } + }) + } +} + +func TestManifestRefusesToMarshalAnExtraThatIsNotJSON(t *testing.T) { + t.Parallel() + + _, err := json.Marshal(Manifest{ + ID: "feed", Name: "Feed", Extra: map[string]json.RawMessage{"note": json.RawMessage(`{`)}, + }) + + if err == nil { + t.Fatal("json.Marshal() error = nil, want an error") + } +} + +func TestManifestReportsAWrongShapeWhenReadBack(t *testing.T) { + t.Parallel() + + var got Manifest + err := json.Unmarshal([]byte(`{"id": "feed", "version": 5}`), &got) + + assertErrorText(t, err, "json: cannot unmarshal number into Go struct field manifest.version of type string") +} diff --git a/pluginkit/wire/manifest_test.go b/pluginkit/wire/manifest_test.go new file mode 100644 index 0000000..bffcd9f --- /dev/null +++ b/pluginkit/wire/manifest_test.go @@ -0,0 +1,739 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "encoding/json" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "testing/fstest" +) + +// fullManifest names every key a manifest can hold, its last three only as raw extras. +const fullManifest = `{ + "id": "events", + "name": "Events", + "backend": "example.com/myapp/plugins/events", + "frontend": "@myapp/plugin-events", + "version": "2.0.0", + "license": "Apache-2.0", + "did": "did:plc:example", + "requires": {"app": ">=0.31.0", "shop": "^2.1"}, + "tested": "0.32", + "engines": ["postgres", "sqlite"], + "uninstall": true, + "hooks": ["content.changed", "page.head"], + "defines": ["shop.order-placed", {"hook": "forum.post-check", "kind": "filter", "fires": "job"}], + "jobs": [{"name": "forget-old", "every": "24h"}], + "settings": [ + {"key": "max_seats", "type": "count", "default": "4", "label": "Seats", "help": "Per booking", "min": 1, "max": 50}, + {"key": "tone", "type": "choice", "options": ["calm", "loud"]}, + {"key": "venue", "type": "picker", "of": "venue"} + ], + "graphql": true, + "menu": [{"label": "Reservations"}], + "screens": "@myapp/plugin-events/screens" +}` + +// number returns a pointer to value, as a setting's limits hold it. +func number(value float64) *float64 { + return &value +} + +// fullManifestRead is what fullManifest reads as. +func fullManifestRead() Manifest { + return Manifest{ + ID: "events", + Name: "Events", + Backend: "example.com/myapp/plugins/events", + Frontend: "@myapp/plugin-events", + Version: "2.0.0", + License: "Apache-2.0", + DID: "did:plc:example", + Requires: map[string]string{"app": ">=0.31.0", "shop": "^2.1"}, + Tested: "0.32", + Engines: []string{"postgres", "sqlite"}, + Uninstall: true, + Hooks: []string{"content.changed", "page.head"}, + Defines: []Define{ + {Hook: "shop.order-placed"}, + {Hook: "forum.post-check", Kind: "filter", Fires: "job"}, + }, + Jobs: []Job{{Name: "forget-old", Every: "24h"}}, + Settings: []Setting{ + { + Key: "max_seats", Type: "count", Default: "4", Label: "Seats", Help: "Per booking", + Min: number(1), Max: number(50), + }, + {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"`), + }, + } +} + +// loadOne reads manifestJSON as the only plugin of a new root, in a folder named dir. +func loadOne(t *testing.T, dir, manifestJSON string) (Manifest, error) { + t.Helper() + root := t.TempDir() + writePlugin(t, root, dir, manifestJSON) + manifests, err := Load(root, []string{"plugins"}, nil) + if err != nil { + return Manifest{}, err + } + if len(manifests) != 1 { + t.Fatalf("Load() = %d manifests, want 1", len(manifests)) + } + return manifests[0], nil +} + +// feedWith reads the feed manifest with keys added, stopping the test when it does not load. +func feedWith(t *testing.T, keys string) Manifest { + t.Helper() + got, err := loadOne(t, "feed", withKeys(feedManifest, keys)) + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + return got +} + +// readOne reads manifestJSON through a plugin's own file system. +func readOne(manifestJSON string) (Manifest, error) { + return Read(fstest.MapFS{"plugin.json": {Data: []byte(manifestJSON)}}) +} + +// assertSame checks that got deeply equals want. +func assertSame(t *testing.T, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} + +// assertErrorText checks that err has exactly the text want. +func assertErrorText(t *testing.T, err error, want string) { + t.Helper() + if err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} + +// manifestPath returns the plugin.json path of a plugin folder under the plugins root of root. +func manifestPath(root, dir string) string { + return filepath.Join(root, "plugins", dir, "plugin.json") +} + +// assertIDs checks that manifests hold exactly the ids want, in order. +func assertIDs(t *testing.T, manifests []Manifest, want ...string) { + t.Helper() + got := make([]string, len(manifests)) + for i, m := range manifests { + got[i] = m.ID + } + if !slices.Equal(got, want) { + t.Errorf("ids = %q, want %q", got, want) + } +} + +// assertErrorFree stops the test when err is set. +func assertErrorFree(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("error = %v, want nil", err) + } +} + +func TestLoadReadsEveryNewField(t *testing.T) { + t.Parallel() + + got, err := loadOne(t, "events", fullManifest) + + assertErrorFree(t, err) + assertSame(t, got, fullManifestRead()) +} + +func TestLoadLeavesAnAbsentOptionalKeyEmpty(t *testing.T) { + t.Parallel() + + got, err := loadOne(t, "feed", feedManifest) + + assertErrorFree(t, err) + assertSame(t, got, Manifest{ + ID: "feed", Name: "Feed", Backend: "example.com/myapp/plugins/feed", Frontend: "@myapp/plugin-feed", + }) +} + +func TestLoadReadsAStringDefineAsAnEntryWithOnlyAHook(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"defines": ["shop.order-placed"]`) + + assertSame(t, got.Defines, []Define{{Hook: "shop.order-placed"}}) +} + +func TestDefineReplacesTheEntryItIsReadInto(t *testing.T) { + t.Parallel() + + for name, data := range map[string]string{"a plain string": `"new"`, "an object": `{"hook": "new"}`} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := Define{Hook: "old", Kind: "filter", Fires: "job"} + + err := json.Unmarshal([]byte(data), &got) + + assertErrorFree(t, err) + assertSame(t, got, Define{Hook: "new"}) + }) + } +} + +func TestDefineKeepsTheEntryItFailsToRead(t *testing.T) { + t.Parallel() + + got := Define{Hook: "old", Kind: "filter", Fires: "job"} + + err := json.Unmarshal([]byte(`{"hook": "new", "kind": 5}`), &got) + + assertErrorText(t, err, "json: cannot unmarshal number into Go struct field define.kind of type string") + assertSame(t, got, Define{Hook: "old", Kind: "filter", Fires: "job"}) +} + +func TestLoadReadsARepeatedDefinesAsTheLastOne(t *testing.T) { + t.Parallel() + + for name, last := range map[string]string{"a plain string": `"b"`, "an object": `{"hook": "b"}`} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"defines": [{"hook": "a", "kind": "filter", "fires": "job"}], "defines": [`+last+`]`) + + assertSame(t, got.Defines, []Define{{Hook: "b"}}) + }) + } +} + +func TestLoadLeavesAnAbsentFiresEmpty(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"defines": [{"hook": "forum.post-check", "kind": "filter"}]`) + + assertSame(t, got.Defines, []Define{{Hook: "forum.post-check", Kind: "filter"}}) +} + +func TestLoadKeepsAZeroLimitApartFromAnAbsentOne(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"settings": [{"key": "seats", "type": "count", "min": 0}]`) + + assertSame(t, got.Settings, []Setting{{Key: "seats", Type: "count", Min: number(0)}}) +} + +func TestLoadKeepsAnUnknownKeyInExtra(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"graphql": true, "pages": [{"word": "forums"}], "note": null`) + + assertSame(t, got.Extra, map[string]json.RawMessage{ + "graphql": json.RawMessage(`true`), + "pages": json.RawMessage(`[{"word": "forums"}]`), + "note": json.RawMessage(`null`), + }) +} + +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`) + + want, err := loadOne(t, "feed", feedManifest) + assertErrorFree(t, err) + assertSame(t, got, want) +} + +func TestLoadReadsANullInsideRequiresAsAnEmptyRange(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"requires": {"app": null}`) + + assertSame(t, got.Requires, map[string]string{"app": ""}) +} + +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}]`) + + 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.Extra, map[string]json.RawMessage(nil)) +} + +func TestLoadAndReadLeaveTheMeaningOfAValueAlone(t *testing.T) { + t.Parallel() + + keys := `"version": "not a version", "license": "not a license", "requires": {"app": "whenever"}, ` + + `"engines": ["no such engine"], "jobs": [{"name": "sweep", "every": "whenever"}], ` + + `"settings": [{"key": "a", "type": "no such type", "min": 9, "max": 1}]` + + got := feedWith(t, keys) + + assertSame(t, got.Version, "not a version") + assertSame(t, got.License, "not a license") + assertSame(t, got.Requires, map[string]string{"app": "whenever"}) + assertSame(t, got.Engines, []string{"no such engine"}) + assertSame(t, got.Jobs, []Job{{Name: "sweep", Every: "whenever"}}) + assertSame(t, got.Settings, []Setting{{Key: "a", Type: "no such type", Min: number(9), Max: number(1)}}) + read, err := readOne(withKeys(feedManifest, keys)) + assertErrorFree(t, err) + assertSame(t, read, got) +} + +func TestReadLeavesTheIDsTheGeneratedWiringOwnsToLoad(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "an id the Go wiring owns": `{"id": "fmt", "name": "Fmt", "backend": "example.com/fmt"}`, + "an id the TypeScript wiring owns": `{"id": "class", "name": "Class", "frontend": "@myapp/plugin-class"}`, + } + for name, manifestJSON := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := readOne(manifestJSON) + + assertErrorFree(t, err) + }) + } +} + +func TestLoadReadsANewKeyInAnyCaseAsEncodingJSONDoes(t *testing.T) { + t.Parallel() + + got := feedWith(t, `"Version": "2.0.0", "ENGINES": ["sqlite"], "job`+"ſ"+`": [{"NAME": "sweep", "Every": "1h"}], `+ + `"defines": [{"HOOK": "a", "Kind": "b", "FIRES": "c"}], "ver-sion": "a", "ver_sion": "b"`) + + assertSame(t, got.Version, "2.0.0") + assertSame(t, got.Engines, []string{"sqlite"}) + assertSame(t, got.Jobs, []Job{{Name: "sweep", Every: "1h"}}) + assertSame(t, got.Defines, []Define{{Hook: "a", Kind: "b", Fires: "c"}}) + assertSame(t, got.Extra, map[string]json.RawMessage{ + "ver-sion": json.RawMessage(`"a"`), + "ver_sion": json.RawMessage(`"b"`), + }) +} + +func TestTypedKeysNameEveryManifestField(t *testing.T) { + t.Parallel() + + var fromTags []string + fields := reflect.TypeFor[Manifest]() + for i := range fields.NumField() { + name, _, _ := strings.Cut(fields.Field(i).Tag.Get("json"), ",") + if name != "-" { + fromTags = append(fromTags, name) + } + } + + assertSame(t, typedKeys, fromTags) +} + +// wrongShape is one set of keys a manifest may not hold, and the text refusing them. +type wrongShape struct{ keys, want string } + +// wrongTypes lists keys of the wrong JSON type, and what the decoder says of each. +func wrongTypes() map[string]wrongShape { + return map[string]wrongShape{ + "a number for version": { + `"version": 5`, "json: cannot unmarshal number into Go struct field manifest.version of type string", + }, + "a number for license": { + `"license": 5`, "json: cannot unmarshal number into Go struct field manifest.license of type string", + }, + "a number for did": { + `"did": 5`, "json: cannot unmarshal number into Go struct field manifest.did of type string", + }, + "a list for requires": { + `"requires": []`, + "json: cannot unmarshal array into Go struct field manifest.requires of type map[string]string", + }, + "a number inside requires": { + `"requires": {"app": 5}`, + "json: cannot unmarshal number into Go struct field manifest.requires.app of type string", + }, + "a number for tested": { + `"tested": 5`, "json: cannot unmarshal number into Go struct field manifest.tested of type string", + }, + "a string for engines": { + `"engines": "postgres"`, + "json: cannot unmarshal string into Go struct field manifest.engines of type []string", + }, + "a number in engines": { + `"engines": [5]`, "json: cannot unmarshal number into manifest.engines.0 of type string", + }, + "a string for uninstall": { + `"uninstall": "yes"`, "json: cannot unmarshal string into Go struct field manifest.uninstall of type bool", + }, + "a string for hooks": { + `"hooks": "page.head"`, "json: cannot unmarshal string into Go struct field manifest.hooks of type []string", + }, + } +} + +// wrongEntryTypes lists keys of the wrong JSON type inside jobs and settings, and what the decoder says of each. +func wrongEntryTypes() map[string]wrongShape { + return map[string]wrongShape{ + "a number for a job name": { + `"jobs": [{"name": 5, "every": "1h"}]`, + "json: cannot unmarshal number into Go struct field manifest.jobs.0.name of type string", + }, + "a number for a job interval": { + `"jobs": [{"name": "a", "every": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.jobs.0.every of type string", + }, + "a number for a setting key": { + `"settings": [{"key": 5, "type": "b"}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.key of type string", + }, + "a number for a setting type": { + `"settings": [{"key": "a", "type": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.type of type string", + }, + "a number for a setting default": { + `"settings": [{"key": "a", "type": "b", "default": 4}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.default of type string", + }, + "a number for a setting label": { + `"settings": [{"key": "a", "type": "b", "label": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.label of type string", + }, + "a number for a setting help": { + `"settings": [{"key": "a", "type": "b", "help": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.help of type string", + }, + "a number for a setting of": { + `"settings": [{"key": "a", "type": "b", "of": 5}]`, + "json: cannot unmarshal number into Go struct field manifest.settings.0.of of type string", + }, + "a string for min": { + `"settings": [{"key": "a", "type": "b", "min": "1"}]`, + "json: cannot unmarshal string into Go struct field manifest.settings.0.min of type float64", + }, + "a string for max": { + `"settings": [{"key": "a", "type": "b", "max": "1"}]`, + "json: cannot unmarshal string into Go struct field manifest.settings.0.max of type float64", + }, + } +} + +// wrongDefines lists defines of the wrong JSON type, and the text naming the entry of each. +func wrongDefines() map[string]wrongShape { + return map[string]wrongShape{ + "a string for defines": { + `"defines": "a"`, + "json: cannot unmarshal string into Go struct field manifest.defines of type []wire.Define", + }, + "a number in defines": { + `"defines": ["a", 5]`, "defines[1]: json: cannot unmarshal number into Go value of type wire.define", + }, + "a list in defines": { + `"defines": [["a"]]`, "defines[0]: json: cannot unmarshal array into Go value of type wire.define", + }, + "true in defines": { + `"defines": [true]`, "defines[0]: json: cannot unmarshal bool into Go value of type wire.define", + }, + "a number for a defined hook": { + `"defines": [{"hook": "a"}, {"hook": 5}]`, + "defines[1]: json: cannot unmarshal number into Go struct field define.hook of type string", + }, + "a number for a defined kind": { + `"defines": [{"hook": "a", "kind": 5}]`, + "defines[0]: json: cannot unmarshal number into Go struct field define.kind of type string", + }, + "a number for a defined fires": { + `"defines": [{"hook": "a", "fires": 5}]`, + "defines[0]: json: cannot unmarshal number into Go struct field define.fires of type string", + }, + "a wrong version before a wrong define": { + `"version": 5, "defines": [5]`, + "json: cannot unmarshal number into Go struct field manifest.version of type string", + }, + } +} + +// emptyValues lists keys that hold nothing where a value is required, and the text wire gives for each. +func emptyValues() map[string]wrongShape { + return map[string]wrongShape{ + "an empty string in engines": {`"engines": ["postgres", ""]`, "engines[1]: must not be empty"}, + "an empty string in hooks": {`"hooks": [""]`, "hooks[0]: must not be empty"}, + "a null in hooks": {`"hooks": ["page.head", null]`, "hooks[1]: must not be empty"}, + "an empty string in defines": {`"defines": [""]`, "defines[0]: hook is required"}, + "a defines entry with no hook": {`"defines": ["a", {"kind": "filter"}]`, "defines[1]: hook is required"}, + "a null in defines": {`"defines": [null]`, "defines[0]: hook is required"}, + "a repeated defines with no hook": { + `"defines": [{"hook": "a"}], "defines": [{"kind": "filter"}]`, "defines[0]: hook is required", + }, + "a repeated defines with a null": {`"defines": [{"hook": "a"}], "defines": [null]`, "defines[0]: hook is required"}, + "a job with no name": {`"jobs": [{"every": "24h"}]`, "jobs[0]: name is required"}, + "a job with no interval": { + `"jobs": [{"name": "a", "every": "1h"}, {"name": "b"}]`, "jobs[1]: every is required", + }, + "a setting with no key": {`"settings": [{"type": "count"}]`, "settings[0]: key is required"}, + "a setting with no type": { + `"settings": [{"key": "a", "type": "b"}, {"key": "c"}]`, "settings[1]: type is required", + }, + "an empty engine before a hook": {`"engines": [""], "hooks": [""]`, "engines[0]: must not be empty"}, + "an empty hook before a define": {`"hooks": [""], "defines": [""]`, "hooks[0]: must not be empty"}, + "an empty define before a job": {`"defines": [""], "jobs": [{}]`, "defines[0]: hook is required"}, + "an empty job before a setting": {`"jobs": [{}], "settings": [{}]`, "jobs[0]: name is required"}, + "an empty string in options": { + `"settings": [{"key": "a", "type": "b", "options": ["c", ""]}]`, + "settings[0].options[1]: must not be empty", + }, + "an empty option in a later setting": { + `"settings": [{"key": "a", "type": "b"}, {"key": "c", "type": "d", "options": [""]}]`, + "settings[1].options[0]: must not be empty", + }, + } +} + +// 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 name, tc := range shapes { + t.Run(name, func(t *testing.T) { + t.Parallel() + + check(t, tc) + }) + } + } +} + +func TestLoadRefusesAWrongShape(t *testing.T) { + t.Parallel() + + eachWrongShape(t, func(t *testing.T, tc wrongShape) { + root := t.TempDir() + writePlugin(t, root, "feed", withKeys(feedManifest, tc.keys)) + + _, err := Load(root, []string{"plugins"}, nil) + + assertErrorText(t, err, "pluginwire: "+manifestPath(root, "feed")+": "+tc.want) + }) +} + +func TestReadRefusesAWrongShape(t *testing.T) { + t.Parallel() + + eachWrongShape(t, func(t *testing.T, tc wrongShape) { + _, err := readOne(withKeys(feedManifest, tc.keys)) + + assertErrorText(t, err, tc.want) + }) +} + +func TestRunRefusesAWrongShapeAndKeepsTheEarlierWiring(t *testing.T) { + t.Parallel() + + eachWrongShape(t, func(t *testing.T, tc wrongShape) { + root, err := runBeside(t, testConfig, "beta", withKeys( + `{"id": "beta", "name": "Beta", "backend": "example.com/myapp/plugins/beta"}`, tc.keys)) + + assertErrorText(t, err, "pluginwire: "+manifestPath(root, "beta")+": "+tc.want) + assertWiringKept(t, testConfig, root) + }) +} + +func TestLoadRefusesEmptyRoots(t *testing.T) { + t.Parallel() + + for name, roots := range map[string][]string{"nil": nil, "empty": {}} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + manifests, err := Load(t.TempDir(), roots, nil) + + assertErrorText(t, err, "wire: Load needs at least one root") + assertSame(t, manifests, []Manifest(nil)) + }) + } +} + +func TestLoadKeepsTodaysErrorTexts(t *testing.T) { + t.Parallel() + + for name, tc := range todaysErrors() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, tc.dir, tc.manifest) + + manifests, err := Load(root, []string{"plugins"}, nil) + + assertErrorText(t, err, "pluginwire: "+manifestPath(root, tc.dir)+": "+tc.want) + assertSame(t, manifests, []Manifest(nil)) + }) + } +} + +func TestLoadKeepsTodaysErrorTextOfAMissingRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + _, err := Load(root, []string{"plugins"}, nil) + + assertErrorText(t, err, + "pluginwire: reading plugins directory: open "+filepath.Join(root, "plugins")+": no such file or directory") +} + +func TestLoadKeepsTodaysErrorTextOfAMissingManifest(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "ghost", "") + + _, err := Load(root, []string{"plugins"}, nil) + + path := manifestPath(root, "ghost") + assertErrorText(t, err, "pluginwire: "+path+": open "+path+": no such file or directory") +} + +func TestLoadRefusesTheIDsRunRefuses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + id, manifest, want string + reserved []string + }{ + "an id the application reserves": { + id: "token", manifest: `{"id": "token", "name": "Token", "frontend": "@myapp/plugin-token"}`, + reserved: []string{"serve", "token"}, want: `id "token" is reserved`, + }, + "an id the Go wiring owns": { + id: "fmt", manifest: `{"id": "fmt", "name": "Fmt", "backend": "example.com/fmt"}`, + want: `id "fmt" collides with the generated Go wiring`, + }, + "an id the TypeScript wiring owns": { + id: "class", manifest: `{"id": "class", "name": "Class", "frontend": "@myapp/plugin-class"}`, + want: `id "class" collides with the generated TypeScript wiring`, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, tc.id, tc.manifest) + + _, err := Load(root, []string{"plugins"}, tc.reserved) + + assertErrorText(t, err, "pluginwire: "+manifestPath(root, tc.id)+": "+tc.want) + }) + } +} + +func TestLoadRefusesAnIDFoundInTwoRoots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + writePluginIn(t, root, "extra", "feed", feedManifest) + + _, err := Load(root, []string{"plugins", "extra"}, nil) + + assertErrorText(t, err, "pluginwire: plugin feed appears under plugins and extra") +} + +func TestLoadReadsEveryRootInOrder(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "zeta", `{"id": "zeta", "name": "Zeta", "backend": "example.com/zeta"}`) + writePluginIn(t, root, "extra", "alpha", `{"id": "alpha", "name": "Alpha", "backend": "example.com/alpha"}`) + + manifests, err := Load(root, []string{"plugins", "extra"}, nil) + + assertErrorFree(t, err) + assertIDs(t, manifests, "zeta", "alpha") +} + +func TestLoadAnswersAnEmptyListWhenARootHoldsNoPlugin(t *testing.T) { + t.Parallel() + + root := t.TempDir() + makeDirs(t, root, "plugins") + + manifests, err := Load(root, []string{"plugins"}, nil) + + assertErrorFree(t, err) + assertSame(t, manifests, []Manifest{}) +} + +func TestLoadStillRefusesAManifestWithOnlyScreens(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", `{"id": "feed", "name": "Feed", "screens": "@myapp/plugin-feed/screens"}`) + + _, err := Load(root, []string{"plugins"}, nil) + + assertErrorText(t, err, + "pluginwire: "+manifestPath(root, "feed")+": at least one of backend or frontend is required") +} + +func TestReadTakesAPluginFileSystem(t *testing.T) { + t.Parallel() + + got, err := readOne(fullManifest) + + assertErrorFree(t, err) + assertSame(t, got, fullManifestRead()) +} + +func TestReadRefusesAManifestThatFailsOnItsOwn(t *testing.T) { + t.Parallel() + + tests := map[string]struct{ manifest, want string }{ + "an id that breaks the pattern": { + `{"id": "Loud", "name": "Loud", "backend": "example.com/loud"}`, `id "Loud" must match ^[a-z][a-z0-9-]*$`, + }, + "a missing name": {`{"id": "feed", "backend": "example.com/feed"}`, "name is required"}, + "neither backend nor frontend": { + `{"id": "feed", "name": "Feed"}`, "at least one of backend or frontend is required", + }, + "malformed json": {`{`, "unexpected end of JSON input"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := readOne(tc.manifest) + + assertErrorText(t, err, tc.want) + assertSame(t, got, Manifest{}) + }) + } +} + +func TestReadReportsAMissingManifestWithNoPathOfItsOwn(t *testing.T) { + t.Parallel() + + got, err := Read(fstest.MapFS{}) + + assertErrorText(t, err, "open plugin.json: file does not exist") + assertSame(t, got, Manifest{}) +} diff --git a/pluginkit/wire/wire.go b/pluginkit/wire/wire.go index 1e2b5fe..ffdf652 100644 --- a/pluginkit/wire/wire.go +++ b/pluginkit/wire/wire.go @@ -1,10 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// Package wire generates plugin wiring and an optional registry from manifests and updates an optional package.json. +// Package wire reads plugin manifests and generates wiring, plus an optional registry, index and package.json update. package wire import ( - "encoding/json" "errors" "fmt" "go/token" @@ -31,6 +30,9 @@ type Config struct { // PackageJSONPath is the optional slash path of a package.json that depends on each frontend, listed in pluginwire. PackageJSONPath string + // ManifestIndexPath is the optional slash path of a generated JSON list of every manifest, in wiring order. + ManifestIndexPath string + // Reserved lists ids no plugin may take. Reserved []string } @@ -63,13 +65,6 @@ func validateConfig(cfg Config) error { return nil } -type manifest struct { - ID string `json:"id"` - Name string `json:"name"` - Backend string `json:"backend"` - Frontend string `json:"frontend"` -} - var idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // loadManifests loads and validates the plugin manifest in each subdirectory of dir. @@ -88,8 +83,8 @@ func loadManifests(dir string) ([]manifest, error) { if err != nil { return nil, fmt.Errorf("pluginwire: %s: %w", path, err) } - var m manifest - if err := json.Unmarshal(data, &m); err != nil { + m, err := parseManifest(data) + if err != nil { return nil, fmt.Errorf("pluginwire: %s: %w", path, err) } if err := validate(m, entry.Name()); err != nil { @@ -154,7 +149,7 @@ func loadRoots(dir string, roots, reserved []string) ([]manifest, error) { return manifests, nil } -// validate checks that manifest m has a well-formed id matching dir, a name, and at least one of backend or frontend. +// validate checks that manifest m has a well-formed id matching dir, a name, a backend or frontend and a sound shape. func validate(m manifest, dir string) error { if !idPattern.MatchString(m.ID) { return fmt.Errorf("id %q must match %s", m.ID, idPattern) @@ -168,7 +163,7 @@ func validate(m manifest, dir string) error { if m.Backend == "" && m.Frontend == "" { return errors.New("at least one of backend or frontend is required") } - return nil + return checkShape(m) } // goName returns id as a valid Go identifier. @@ -282,6 +277,13 @@ 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.ManifestIndexPath != "" { + index, err := generateIndex(manifests) + if err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", cfg.ManifestIndexPath, err) + } + files = append(files, output{path: cfg.ManifestIndexPath, data: index}) + } if cfg.PackageJSONPath == "" { return files, nil } @@ -292,7 +294,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, optional registry and optional package.json from the manifests under root. +// Run writes the Go and TypeScript wiring and any optional registry, index and package.json from the manifests in root. func Run(root string, cfg Config) error { if err := validateConfig(cfg); err != nil { return err