diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 859988e..5ac6835 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -3,6 +3,11 @@ 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 + +- The module needs Go 1.27.2. +- `wire.Config` gains an optional `PackageJSONPath`, a package.json where `wire.Run` lists every plugin frontend as `workspace:*`, records those names under `pluginwire` and drops a recorded name no manifest names. + ## 0.6.0 - 2026-09-26 - The module moved to `github.com/gopherium/framework/pluginkit`. diff --git a/pluginkit/wire/packagejson.go b/pluginkit/wire/packagejson.go new file mode 100644 index 0000000..31a0242 --- /dev/null +++ b/pluginkit/wire/packagejson.go @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "bytes" + "encoding/json" + "encoding/json/jsontext" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" +) + +// The names and the dependency value that syncPackageJSON writes. +const ( + dependenciesKey = "dependencies" + recordKey = "pluginwire" + workspaceDependency = `"workspace:*"` +) + +// errRecord reports a pluginwire record that is not an array of strings. +var errRecord = errors.New("pluginwire.dependencies is not an array of strings") + +// member is one name and raw value of a JSON object. +type member struct { + key jsontext.Token + value jsontext.Value +} + +// name returns the unquoted name of m. +func (m member) name() string { + return m.key.String() +} + +// frontendPackages returns the sorted unique frontend package names of manifests. +func frontendPackages(manifests []manifest) []string { + names := make([]string, 0, len(manifests)) + for _, m := range withFrontend(manifests) { + names = append(names, m.Frontend) + } + slices.Sort(names) + return slices.Compact(names) +} + +// syncedPackageJSON reads the package.json at path and syncs it to the frontends of manifests. +func syncedPackageJSON(path string, manifests []manifest) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } + synced, err := syncPackageJSON(data, frontendPackages(manifests)) + if err != nil { + return nil, fmt.Errorf("pluginwire: %s: %w", path, err) + } + return synced, nil +} + +// syncPackageJSON returns data with every name in want as a workspace dependency recorded under pluginwire. +func syncPackageJSON(data []byte, want []string) ([]byte, error) { + members, err := readObject(data, "the top level") + if err != nil { + return nil, err + } + deps, err := readDependencies(members) + if err != nil { + return nil, err + } + recorded, err := readRecord(members) + if err != nil { + return nil, err + } + if _, found := find(members, recordKey); len(want) == 0 && !found { + return data, nil + } + return render(layout(members, want), dependencies(deps, recorded, want), want) +} + +// openObject reads the opening brace from dec, naming the value what when it is empty or not an object. +func openObject(dec *jsontext.Decoder, what string) error { + open, err := dec.ReadToken() + if errors.Is(err, io.EOF) { + return fmt.Errorf("%s is empty", what) + } + if err != nil { + return err + } + if open.Kind() != '{' { + return fmt.Errorf("%s is not an object", what) + } + return nil +} + +// readObject returns the members of the one JSON object in data, naming it what when it is empty or not an object. +func readObject(data []byte, what string) ([]member, error) { + dec := jsontext.NewDecoder(bytes.NewReader(data)) + if err := openObject(dec, what); err != nil { + return nil, err + } + var members []member + for dec.PeekKind() == '"' { + key, err := dec.ReadToken() + if err != nil { + return nil, err + } + key = key.Clone() + value, err := dec.ReadValue() + if err != nil { + return nil, err + } + members = append(members, member{key: key, value: value.Clone()}) + } + if _, err := dec.ReadToken(); err != nil { + return nil, err + } + if _, err := dec.ReadToken(); !errors.Is(err, io.EOF) { + return nil, errors.New("data after the closing brace") + } + return members, nil +} + +// find returns the member of members called name. +func find(members []member, name string) (member, bool) { + i := slices.IndexFunc(members, func(m member) bool { return m.name() == name }) + if i < 0 { + return member{}, false + } + return members[i], true +} + +// readDependencies returns the members of the dependencies object in members, each a string. +func readDependencies(members []member) ([]member, error) { + m, found := find(members, dependenciesKey) + if !found { + return nil, nil + } + deps, err := readObject(m.value, dependenciesKey) + if err != nil { + return nil, err + } + for _, dep := range deps { + if dep.value.Kind() != '"' { + return nil, fmt.Errorf("dependency %q is not a string", dep.name()) + } + } + return deps, nil +} + +// readRecord returns the names in the pluginwire record of members. +func readRecord(members []member) ([]string, error) { + m, found := find(members, recordKey) + if !found { + return nil, nil + } + record, err := readObject(m.value, recordKey) + if err != nil { + return nil, err + } + names, found := find(record, dependenciesKey) + if !found { + return nil, errRecord + } + return readStrings(names.value) +} + +// readStrings returns the strings of the JSON array in value. +func readStrings(value jsontext.Value) ([]string, error) { + var items []*string + if err := json.Unmarshal(value, &items); err != nil || items == nil { + return nil, errRecord + } + names := make([]string, 0, len(items)) + for _, item := range items { + if item == nil { + return nil, errRecord + } + names = append(names, *item) + } + return names, nil +} + +// layout returns the top-level members to write, without the record when want is empty and with missing keys last. +func layout(members []member, want []string) []member { + if len(want) == 0 { + return slices.DeleteFunc(slices.Clone(members), func(m member) bool { return m.name() == recordKey }) + } + out := slices.Clone(members) + for _, name := range []string{dependenciesKey, recordKey} { + if _, found := find(out, name); !found { + out = append(out, member{key: jsontext.String(name)}) + } + } + return out +} + +// dependencies returns deps without the recorded names and with every name in want as a workspace dependency, by name. +func dependencies(deps []member, recorded, want []string) []member { + out := make([]member, 0, len(deps)+len(want)) + for _, dep := range deps { + if !slices.Contains(recorded, dep.name()) && !slices.Contains(want, dep.name()) { + out = append(out, dep) + } + } + for _, name := range want { + out = append(out, member{key: jsontext.String(name), value: jsontext.Value(workspaceDependency)}) + } + slices.SortFunc(out, func(a, b member) int { return strings.Compare(a.name(), b.name()) }) + return out +} + +// render returns members as tab-indented JSON text with deps as the dependencies and want as the record. +func render(members, deps []member, want []string) ([]byte, error) { + var buf bytes.Buffer + w := &writer{enc: jsontext.NewEncoder(&buf, + jsontext.Multiline(true), jsontext.WithIndent("\t"), jsontext.PreserveRawStrings(true))} + w.token(jsontext.BeginObject) + for _, m := range members { + w.token(m.key) + switch m.name() { + case dependenciesKey: + w.object(deps) + case recordKey: + w.record(want) + default: + w.value(m.value) + } + } + w.token(jsontext.EndObject) + if w.err != nil { + return nil, w.err + } + return buf.Bytes(), nil +} + +// writer writes JSON tokens and values to an encoder and keeps the first error. +type writer struct { + enc *jsontext.Encoder + err error +} + +// token writes t unless an earlier write failed. +func (w *writer) token(t jsontext.Token) { + if w.err == nil { + w.err = w.enc.WriteToken(t) + } +} + +// value writes v unless an earlier write failed. +func (w *writer) value(v jsontext.Value) { + if w.err == nil { + w.err = w.enc.WriteValue(v) + } +} + +// object writes members as one JSON object. +func (w *writer) object(members []member) { + w.token(jsontext.BeginObject) + for _, m := range members { + w.token(m.key) + w.value(m.value) + } + w.token(jsontext.EndObject) +} + +// record writes names as the pluginwire record. +func (w *writer) record(names []string) { + w.token(jsontext.BeginObject) + w.token(jsontext.String(dependenciesKey)) + w.token(jsontext.BeginArray) + for _, name := range names { + w.token(jsontext.String(name)) + } + w.token(jsontext.EndArray) + w.token(jsontext.EndObject) +} diff --git a/pluginkit/wire/packagejson_test.go b/pluginkit/wire/packagejson_test.go new file mode 100644 index 0000000..725a5a4 --- /dev/null +++ b/pluginkit/wire/packagejson_test.go @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wire + +import ( + "slices" + "strings" + "testing" +) + +// assertSynced checks that syncPackageJSON turns input into want for frontends. +func assertSynced(t *testing.T, input string, frontends []string, want string) { + t.Helper() + got, err := syncPackageJSON([]byte(input), frontends) + if err != nil { + t.Fatalf("syncPackageJSON() error = %v, want nil", err) + } + if string(got) != want { + t.Errorf("syncPackageJSON() = %q, want %q", got, want) + } +} + +func TestSyncPackageJSONAddsAndRecordsANewPlugin(t *testing.T) { + t.Parallel() + + input := `{ + "name": "@myapp/frontend", + "private": true, + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "react": "^19.0.0" + } +} +` + want := `{ + "name": "@myapp/frontend", + "private": true, + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-feed": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +` + assertSynced(t, input, []string{"@myapp/plugin-feed"}, want) +} + +func TestSyncPackageJSONKeepsEveryUnrecordedDependency(t *testing.T) { + t.Parallel() + + input := `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-feed": "workspace:*", + "@myapp/ui-primitives": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + }, + "devDependencies": { + "@myapp/test-kit": "workspace:*", + "vitest": "^5.0.0" + } +} +` + want := `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-feed": "workspace:*", + "@myapp/plugin-gallery": "workspace:*", + "@myapp/ui-primitives": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed", + "@myapp/plugin-gallery" + ] + }, + "devDependencies": { + "@myapp/test-kit": "workspace:*", + "vitest": "^5.0.0" + } +} +` + assertSynced(t, input, []string{"@myapp/plugin-feed", "@myapp/plugin-gallery"}, want) +} + +func TestSyncPackageJSONDropsARecordedPluginNoManifestNames(t *testing.T) { + t.Parallel() + + input := `{ + "dependencies": { + "@myapp/plugin-feed": "workspace:*", + "@myapp/plugin-gallery": "workspace:*", + "@myapp/plugin-legacy": "workspace:*" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed", + "@myapp/plugin-gallery" + ] + } +} +` + want := `{ + "dependencies": { + "@myapp/plugin-feed": "workspace:*", + "@myapp/plugin-legacy": "workspace:*" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +` + assertSynced(t, input, []string{"@myapp/plugin-feed"}, want) +} + +func TestSyncPackageJSONTakesOverAHandWrittenEntry(t *testing.T) { + t.Parallel() + + input := `{ + "dependencies": { + "@myapp/plugin-feed": "^1.2.0", + "react": "^19.0.0" + } +} +` + want := `{ + "dependencies": { + "@myapp/plugin-feed": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +` + assertSynced(t, input, []string{"@myapp/plugin-feed"}, want) +} + +func TestSyncPackageJSONRefusesMalformedInput(t *testing.T) { + t.Parallel() + + const notStrings = "pluginwire.dependencies is not an array of strings" + tests := map[string]struct { + input string + want string + }{ + "empty file": {input: ``, want: "the top level is empty"}, + "only whitespace": {input: " \r\n\t", want: "the top level is empty"}, + "bad syntax": {input: `{"name": }`, want: "invalid character '}'"}, + "unclosed object": {input: `{"name": "a"`, want: "unexpected EOF"}, + "trailing comma": {input: `{"name": "a",}`, want: "invalid character ','"}, + "name not a string": {input: `{1: "a"}`, want: "object member name must be a string"}, + "invalid UTF-8": {input: "{\"name\": \"\xff\"}", want: "invalid UTF-8"}, + "array at the top level": {input: `[]`, want: "the top level is not an object"}, + "a second value at the end": {input: `{} {}`, want: "data after the closing brace"}, + "text at the end": {input: `{} x`, want: "data after the closing brace"}, + "duplicate name": {input: `{"name": "a", "name": "b"}`, want: `duplicate object member name "name"`}, + "dependencies not an object": {input: `{"dependencies": []}`, want: "dependencies is not an object"}, + "duplicate dependency": { + input: `{"dependencies": {"react": "^19.0.0", "react": "^18.0.0"}}`, + want: `duplicate object member name "react"`, + }, + "null dependency": {input: `{"dependencies": {"react": null}}`, want: `dependency "react" is not a string`}, + "number dependency": {input: `{"dependencies": {"react": 19}}`, want: `dependency "react" is not a string`}, + "pluginwire not an object": {input: `{"pluginwire": []}`, want: "pluginwire is not an object"}, + "record missing": {input: `{"pluginwire": {}}`, want: notStrings}, + "record an object": {input: `{"pluginwire": {"dependencies": {}}}`, want: notStrings}, + "record null": {input: `{"pluginwire": {"dependencies": null}}`, want: notStrings}, + "record holding a number": {input: `{"pluginwire": {"dependencies": [1]}}`, want: notStrings}, + "record holding null": {input: `{"pluginwire": {"dependencies": [null]}}`, want: notStrings}, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + for _, frontends := range [][]string{nil, {"@myapp/plugin-feed"}} { + got, err := syncPackageJSON([]byte(tc.input), frontends) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("syncPackageJSON(%d frontends) = %q, %v, want an error containing %q", + len(frontends), got, err, tc.want) + } + } + }) + } +} + +func TestSyncPackageJSONRefusesAFrontendNameThatIsNotUTF8(t *testing.T) { + t.Parallel() + + got, err := syncPackageJSON([]byte(`{}`), []string{"@myapp/plugin-\xff"}) + + if err == nil || !strings.Contains(err.Error(), "invalid UTF-8") { + t.Errorf("syncPackageJSON() = %q, %v, want an invalid UTF-8 error", got, err) + } +} + +func TestSyncPackageJSONKeepsACanonicalFileOnTheFirstRun(t *testing.T) { + t.Parallel() + + canonical := `{ + "name": "@myapp/frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "lint:css": "stylelint \"src/**/*.css\"" + }, + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-feed": "workspace:*", + "react": "^19.0.0" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vitest": "^5.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +` + assertSynced(t, canonical, []string{"@myapp/plugin-feed"}, canonical) +} + +func TestSyncPackageJSONKeepsEscapedAndLiteralText(t *testing.T) { + t.Parallel() + + canonical := "{\n" + + "\t\"description\": \"Caf\\u00e9 menu\",\n" + + "\t\"title\": \"Café menu\",\n" + + "\t\"scripts\": {\n" + + "\t\t\"greet\": \"echo Caf\\u00e9\"\n" + + "\t},\n" + + "\t\"dependencies\": {\n" + + "\t\t\"@myapp/plugin-feed\": \"workspace:*\",\n" + + "\t\t\"caf\\u00e9-icons\": \"npm:caf\\u00e9-icons@^1.0.0\"\n" + + "\t},\n" + + "\t\"pluginwire\": {\n" + + "\t\t\"dependencies\": [\n" + + "\t\t\t\"@myapp/plugin-feed\"\n" + + "\t\t]\n" + + "\t}\n" + + "}\n" + + assertSynced(t, canonical, []string{"@myapp/plugin-feed"}, canonical) +} + +func TestSyncPackageJSONIsStableOnItsOwnOutput(t *testing.T) { + t.Parallel() + + inputs := map[string]string{ + "unsorted dependencies": `{"dependencies": {"react": "^19.0.0", "@myapp/frontend-sdk": "workspace:*"}}`, + "no dependencies key": `{"name": "@myapp/frontend"}`, + "a stale record": `{"dependencies": {"@myapp/plugin-gone": "workspace:*"}, ` + + `"pluginwire": {"dependencies": ["@myapp/plugin-gone"]}}`, + } + + for testName, input := range inputs { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + first, err := syncPackageJSON([]byte(input), []string{"@myapp/plugin-feed"}) + if err != nil { + t.Fatalf("first syncPackageJSON() error = %v, want nil", err) + } + second, err := syncPackageJSON(first, []string{"@myapp/plugin-feed"}) + if err != nil { + t.Fatalf("second syncPackageJSON() error = %v, want nil", err) + } + if string(second) != string(first) { + t.Errorf("second run = %q, want the first run %q", second, first) + } + }) + } +} + +func TestSyncPackageJSONRewritesSpacesAndCRLFToTabsOnce(t *testing.T) { + t.Parallel() + + spaced := `{ + "name": "@myapp/frontend", + "dependencies": { + "react": "^19.0.0" + } +} +` + want := `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/plugin-feed": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +` + inputs := map[string]string{"spaces": spaced, "spaces and CRLF": strings.ReplaceAll(spaced, "\n", "\r\n")} + + for testName, input := range inputs { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assertSynced(t, input, []string{"@myapp/plugin-feed"}, want) + assertSynced(t, want, []string{"@myapp/plugin-feed"}, want) + }) + } +} + +func TestSyncPackageJSONLeavesAFileWithoutFrontendsOrRecordUntouched(t *testing.T) { + t.Parallel() + + inputs := map[string]string{ + "unsorted and space indented": `{ + "dependencies": { + "react": "^19.0.0", + "@myapp/frontend-sdk": "workspace:*" + } +}`, + "no dependencies key": `{"name": "@myapp/frontend"}`, + } + + for testName, input := range inputs { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assertSynced(t, input, nil, input) + }) + } +} + +func TestSyncPackageJSONDropsTheRecordWhenNoFrontendRemains(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input string + want string + }{ + "other dependencies remain": { + input: `{ + "dependencies": { + "@myapp/plugin-feed": "workspace:*", + "react": "^19.0.0" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + }, + "devDependencies": { + "vitest": "^5.0.0" + } +} +`, + want: `{ + "dependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "vitest": "^5.0.0" + } +} +`, + }, + "no dependency remains": { + input: `{"dependencies": {"@myapp/plugin-feed": "workspace:*"}, ` + + `"pluginwire": {"dependencies": ["@myapp/plugin-feed"]}}`, + want: `{ + "dependencies": {} +} +`, + }, + "no dependencies key": { + input: `{"name": "@myapp/frontend", "pluginwire": {"dependencies": []}}`, + want: `{ + "name": "@myapp/frontend" +} +`, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assertSynced(t, tc.input, nil, tc.want) + }) + } +} + +func TestSyncPackageJSONAddsDependenciesLastWhenMissing(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input string + want string + }{ + "no record either": { + input: `{ + "name": "@myapp/frontend", + "devDependencies": { + "vitest": "^5.0.0" + } +} +`, + want: `{ + "name": "@myapp/frontend", + "devDependencies": { + "vitest": "^5.0.0" + }, + "dependencies": { + "@myapp/plugin-feed": "workspace:*" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + } +} +`, + }, + "a record already in place": { + input: `{"pluginwire": {"dependencies": []}, "name": "@myapp/frontend"}`, + want: `{ + "pluginwire": { + "dependencies": [ + "@myapp/plugin-feed" + ] + }, + "name": "@myapp/frontend", + "dependencies": { + "@myapp/plugin-feed": "workspace:*" + } +} +`, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assertSynced(t, tc.input, []string{"@myapp/plugin-feed"}, tc.want) + }) + } +} + +func TestFrontendPackagesSortsEveryNameOnce(t *testing.T) { + t.Parallel() + + got := frontendPackages([]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"}, + }) + + want := []string{"@myapp/plugin-audit", "@myapp/plugin-gallery"} + if !slices.Equal(got, want) { + t.Errorf("frontendPackages() = %q, want %q", got, want) + } +} diff --git a/pluginkit/wire/wire.go b/pluginkit/wire/wire.go index 5d54514..1e2b5fe 100644 --- a/pluginkit/wire/wire.go +++ b/pluginkit/wire/wire.go @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// Package wire generates an application's plugin wiring files from the -// plugin.json manifest of every directory under each configured plugin root. +// Package wire generates plugin wiring and an optional registry from manifests and updates an optional package.json. package wire import ( @@ -29,6 +28,9 @@ 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 string + // Reserved lists ids no plugin may take. Reserved []string } @@ -243,14 +245,14 @@ func renderRegistration(cfg Config, manifests []manifest, pkg, doc, funcName str return []byte(b.String()) } +// withFrontend returns the manifests that name a frontend, in order. +func withFrontend(manifests []manifest) []manifest { + return slices.DeleteFunc(slices.Clone(manifests), func(m manifest) bool { return m.Frontend == "" }) +} + // generateTS renders the generated TypeScript plugin-wiring file. func generateTS(cfg Config, manifests []manifest) []byte { - frontends := make([]manifest, 0, len(manifests)) - for _, m := range manifests { - if m.Frontend != "" { - frontends = append(frontends, m) - } - } + frontends := withFrontend(manifests) var b strings.Builder b.WriteString(generatedHeader(cfg.tsLicense())) @@ -265,8 +267,32 @@ func generateTS(cfg Config, manifests []manifest) []byte { return []byte(b.String()) } -// Run loads the plugin manifests under root and writes the application's -// generated Go and TypeScript wiring files per cfg. +// output is one file Run writes, its slash path under the root and its contents. +type output struct { + path string + data []byte +} + +// outputs renders every file Run writes per cfg. +func outputs(root string, cfg Config, manifests []manifest) ([]output, error) { + files := []output{ + {path: cfg.GoWiringPath, data: generateGo(cfg, manifests)}, + {path: cfg.TSWiringPath, data: generateTS(cfg, manifests)}, + } + if cfg.GoRegistryPath != "" { + files = append(files, output{path: cfg.GoRegistryPath, data: generateRegistry(cfg, manifests)}) + } + if cfg.PackageJSONPath == "" { + return files, nil + } + packageJSON, err := syncedPackageJSON(filepath.Join(root, filepath.FromSlash(cfg.PackageJSONPath)), manifests) + if err != nil { + return nil, err + } + 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. func Run(root string, cfg Config) error { if err := validateConfig(cfg); err != nil { return err @@ -275,20 +301,14 @@ func Run(root string, cfg Config) error { if err != nil { return err } - goPath := filepath.Join(root, filepath.FromSlash(cfg.GoWiringPath)) - if err := os.WriteFile(goPath, generateGo(cfg, manifests), 0o644); err != nil { - return fmt.Errorf("pluginwire: %w", err) - } - tsPath := filepath.Join(root, filepath.FromSlash(cfg.TSWiringPath)) - if err := os.WriteFile(tsPath, generateTS(cfg, manifests), 0o644); err != nil { - return fmt.Errorf("pluginwire: %w", err) - } - if cfg.GoRegistryPath == "" { - return nil + files, err := outputs(root, cfg, manifests) + if err != nil { + return err } - registryPath := filepath.Join(root, filepath.FromSlash(cfg.GoRegistryPath)) - if err := os.WriteFile(registryPath, generateRegistry(cfg, manifests), 0o644); err != nil { - return fmt.Errorf("pluginwire: %w", err) + for _, file := range files { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(file.path)), file.data, 0o644); err != nil { + return fmt.Errorf("pluginwire: %w", err) + } } return nil } diff --git a/pluginkit/wire/wire_test.go b/pluginkit/wire/wire_test.go index d5beb27..19233a5 100644 --- a/pluginkit/wire/wire_test.go +++ b/pluginkit/wire/wire_test.go @@ -43,6 +43,38 @@ func writePluginIn(t *testing.T, root, pluginRoot, dir, manifestJSON string) { } } +// makeDirs creates each slash path directory under root. +func makeDirs(t *testing.T, root string, dirs ...string) { + t.Helper() + for _, dir := range dirs { + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { + t.Fatalf("creating %s: %v", dir, err) + } + } +} + +// readFile returns the contents of the slash path under root. +func readFile(t *testing.T, root, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + return string(data) +} + +// writeFile writes content to the slash path under root, creating its directory. +func writeFile(t *testing.T, root, path, content string) { + t.Helper() + full := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("creating the directory of %s: %v", path, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("writing %s: %v", path, err) + } +} + func TestLoadManifestsReadsValidPlugins(t *testing.T) { t.Parallel() @@ -625,22 +657,23 @@ func TestRunRejectsDuplicateIDAcrossRoots(t *testing.T) { // earlierWiring is what the wiring files hold before a refused run. const earlierWiring = "earlier wiring\n" -// runBeside writes an alpha plugin, the manifest of id and earlier wiring files, then runs the generator. -func runBeside(t *testing.T, cfg Config, id, manifestJSON string) (string, error) { +// writeBeside writes an alpha plugin, the manifest of id and earlier wiring files under a new root. +func writeBeside(t *testing.T, cfg Config, id, manifestJSON string) string { t.Helper() root := t.TempDir() writePlugin(t, root, "alpha", `{"id": "alpha", "name": "Alpha", "backend": "example.com/myapp/plugins/alpha", "frontend": "@myapp/plugin-alpha"}`) writePlugin(t, root, id, manifestJSON) for _, path := range []string{cfg.GoWiringPath, cfg.TSWiringPath} { - wiring := filepath.Join(root, filepath.FromSlash(path)) - if err := os.MkdirAll(filepath.Dir(wiring), 0o755); err != nil { - t.Fatalf("creating the wiring directory: %v", err) - } - if err := os.WriteFile(wiring, []byte(earlierWiring), 0o644); err != nil { - t.Fatalf("writing the earlier wiring: %v", err) - } + writeFile(t, root, path, earlierWiring) } + return root +} + +// runBeside writes the tree of writeBeside, then runs the generator. +func runBeside(t *testing.T, cfg Config, id, manifestJSON string) (string, error) { + t.Helper() + root := writeBeside(t, cfg, id, manifestJSON) return root, Run(root, cfg) } @@ -651,6 +684,12 @@ func assertRefused(t *testing.T, cfg Config, root, id, reason string, err error) if err == nil || err.Error() != want { t.Errorf("Run() error = %v, want %q", err, want) } + assertWiringKept(t, cfg, root) +} + +// assertWiringKept checks that the wiring files under root still hold the earlier wiring. +func assertWiringKept(t *testing.T, cfg Config, root string) { + t.Helper() for _, path := range []string{cfg.GoWiringPath, cfg.TSWiringPath} { if kept, _ := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))); string(kept) != earlierWiring { t.Errorf("%s after the refusal = %q, want the earlier wiring untouched", path, kept) @@ -762,6 +801,123 @@ func TestRunAcceptsAnIDOnlyTheOtherWiringOwns(t *testing.T) { } } +func TestRunReadsNoPackageJSONWhenUnset(t *testing.T) { + t.Parallel() + + root := writeBeside(t, testConfig, "beta", `{"id": "beta", "name": "Beta", "frontend": "@myapp/plugin-beta"}`) + writeFile(t, root, "frontend/package.json", "{") + + if err := Run(root, testConfig); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + if kept, _ := os.ReadFile(filepath.Join(root, "frontend", "package.json")); string(kept) != "{" { + t.Errorf("package.json = %q, want the malformed file untouched", kept) + } +} + +func TestRunWritesNoPackageJSONWhenUnset(t *testing.T) { + t.Parallel() + + root, err := runBeside(t, testConfig, "beta", `{"id": "beta", "name": "Beta", "frontend": "@myapp/plugin-beta"}`) + + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + if _, err := os.Stat(filepath.Join(root, "frontend", "package.json")); !os.IsNotExist(err) { + t.Errorf("package.json exists without PackageJSONPath, stat error = %v", err) + } +} + +func TestRunRefusesAMalformedPackageJSONBeforeAnyWrite(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.PackageJSONPath = "frontend/package.json" + cfg.GoRegistryPath = "internal/graphroot/registry_gen.go" + cfg.GoRegistryPackage = "graphroot" + root := writeBeside(t, cfg, "beta", `{"id": "beta", "name": "Beta", "frontend": "@myapp/plugin-beta"}`) + writeFile(t, root, cfg.PackageJSONPath, `{"dependencies": []}`) + makeDirs(t, root, "internal/graphroot") + + err := Run(root, cfg) + + want := "pluginwire: " + filepath.Join(root, "frontend", "package.json") + ": dependencies is not an object" + if err == nil || err.Error() != want { + t.Errorf("Run() error = %v, want %q", err, want) + } + assertWiringKept(t, cfg, root) + if kept, _ := os.ReadFile(filepath.Join(root, "frontend", "package.json")); string(kept) != `{"dependencies": []}` { + t.Errorf("package.json after the refusal = %q, want it untouched", kept) + } + if _, err := os.Stat(filepath.Join(root, "internal", "graphroot", "registry_gen.go")); !os.IsNotExist(err) { + t.Errorf("registry_gen.go exists after the refusal, stat error = %v", err) + } +} + +func TestRunReportsAManifestRefusalBeforeAPackageJSONError(t *testing.T) { + t.Parallel() + + cfg := testConfig + cfg.Reserved = []string{"token"} + cfg.PackageJSONPath = "frontend/package.json" + + root, err := runBeside(t, cfg, "token", + `{"id": "token", "name": "Token", "backend": "example.com/myapp/plugins/token"}`) + + assertRefused(t, cfg, root, "token", "is reserved", err) +} + +func TestRunWritesPackageJSONFromEveryRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePlugin(t, root, "feed", feedManifest) + writePluginIn(t, root, "enterprise", "audit", `{"id": "audit", "name": "Audit", "frontend": "@myapp/plugin-audit"}`) + makeDirs(t, root, "cmd/myapp", "frontend/src/plugins") + writeFile(t, root, "frontend/package.json", `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*" + } +} +`) + cfg := testConfig + cfg.Roots = []string{"plugins", "enterprise"} + cfg.PackageJSONPath = "frontend/package.json" + + if err := Run(root, cfg); err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + want := `{ + "name": "@myapp/frontend", + "dependencies": { + "@myapp/frontend-sdk": "workspace:*", + "@myapp/plugin-audit": "workspace:*", + "@myapp/plugin-feed": "workspace:*" + }, + "pluginwire": { + "dependencies": [ + "@myapp/plugin-audit", + "@myapp/plugin-feed" + ] + } +} +` + if got := readFile(t, root, "frontend/package.json"); got != want { + t.Errorf("package.json = %q, want %q", got, want) + } + rootOrder := "import { plugin as feed } from '@myapp/plugin-feed'\n" + + "import { plugin as audit } from '@myapp/plugin-audit'\n" + if tsSrc := readFile(t, root, "frontend/src/plugins/index.ts"); !strings.Contains(tsSrc, rootOrder) { + t.Errorf("index.ts = %q, want the feed import before the audit import, in root order", tsSrc) + } + if _, err := os.Stat(filepath.Join(root, "cmd", "myapp", "plugins_gen.go")); err != nil { + t.Errorf("plugins_gen.go beside package.json, stat error = %v, want nil", err) + } +} + func TestRunRejectsIncompleteConfig(t *testing.T) { t.Parallel() @@ -791,12 +947,22 @@ func TestRunReportsFailures(t *testing.T) { t.Parallel() tests := map[string]struct { - roots []string - prepare func(t *testing.T, root string) + roots []string + packageJSON string + names string + prepare func(t *testing.T, root string) }{ "missing plugins directory": { prepare: func(_ *testing.T, _ string) {}, }, + "missing package.json": { + packageJSON: "frontend/package.json", + names: "frontend/package.json", + prepare: func(t *testing.T, root string) { + writePlugin(t, root, "feed", feedManifest) + makeDirs(t, root, "cmd/myapp", "frontend/src/plugins") + }, + }, "missing named root": { roots: []string{"plugins", "enterprise"}, prepare: func(t *testing.T, root string) { @@ -831,10 +997,20 @@ func TestRunReportsFailures(t *testing.T) { tc.prepare(t, root) cfg := testConfig cfg.Roots = tc.roots + cfg.PackageJSONPath = tc.packageJSON - if err := Run(root, cfg); err == nil { + err := Run(root, cfg) + + if err == nil { t.Fatal("Run() error = nil, want an error") } + if tc.names == "" { + return + } + want := "pluginwire: " + filepath.Join(root, filepath.FromSlash(tc.names)) + ": " + if !strings.HasPrefix(err.Error(), want) { + t.Errorf("Run() error = %q, want it to start with %q", err, want) + } }) } }