feat(01-03): add make:plugin and plugin:add scaffolding
- Scaffold a compiling vendor.plugin module with go.mod, plugin.go and config/ - Register the module once in summer.yaml, go.work and the app go.mod - Keep summer build on the same generate-and-compile path and print elapsed time
This commit is contained in:
@@ -3,6 +3,7 @@ package build
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -121,6 +122,296 @@ func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakePluginRejectsInvalidID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "summer.yaml"), "module: example.com/app\nbinary: app\nplugins:\n")
|
||||
write(t, filepath.Join(dir, "go.mod"), "module example.com/app\n\ngo 1.27.0\n")
|
||||
|
||||
for _, id := range []string{"Golem15.Demo", "golem15", "golem15.demo/../tmp", "../evil.plugin"} {
|
||||
if _, err := MakePlugin(t.Context(), dir, id); err == nil {
|
||||
t.Fatalf("id %q: want error", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
|
||||
dir := copyHelloApp(t)
|
||||
ctx := t.Context()
|
||||
|
||||
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir := filepath.Join(dir, "plugins", "demo")
|
||||
if pluginDir != wantDir {
|
||||
t.Fatalf("plugin dir = %s, want %s", pluginDir, wantDir)
|
||||
}
|
||||
assertScaffoldFiles(t, pluginDir)
|
||||
|
||||
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
|
||||
t.Fatalf("repeat plugin:add: %v", err)
|
||||
}
|
||||
|
||||
m, err := LoadManifest(filepath.Join(dir, "summer.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Plugins) != 4 {
|
||||
t.Fatalf("plugins = %+v", m.Plugins)
|
||||
}
|
||||
last := m.Plugins[len(m.Plugins)-1]
|
||||
if last.ID != "golem15.demo" {
|
||||
t.Fatalf("last plugin id = %q", last.ID)
|
||||
}
|
||||
if last.Module != m.Module+"/plugins/demo" {
|
||||
t.Fatalf("last plugin module = %q", last.Module)
|
||||
}
|
||||
demoCount := 0
|
||||
for _, p := range m.Plugins {
|
||||
if p.ID == "golem15.demo" {
|
||||
demoCount++
|
||||
}
|
||||
}
|
||||
if demoCount != 1 {
|
||||
t.Fatalf("golem15.demo count = %d", demoCount)
|
||||
}
|
||||
|
||||
work, err := os.ReadFile(filepath.Join(dir, "go.work"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(work, []byte("plugins/demo")) {
|
||||
t.Fatalf("go.work missing plugins/demo:\n%s", work)
|
||||
}
|
||||
appMod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(appMod, []byte(last.Module)) {
|
||||
t.Fatalf("app go.mod missing %s:\n%s", last.Module, appMod)
|
||||
}
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := App(ctx, dir, &first); err != nil {
|
||||
t.Fatalf("first build: %v\n%s", err, first.String())
|
||||
}
|
||||
if !strings.Contains(first.String(), "built hello in ") {
|
||||
t.Fatalf("missing elapsed build time:\n%s", first.String())
|
||||
}
|
||||
main1, err := os.ReadFile(filepath.Join(dir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen1, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(gen1, []byte(last.Module)) {
|
||||
t.Fatalf("plugins.gen.go missing %s:\n%s", last.Module, gen1)
|
||||
}
|
||||
if !bytes.Contains(gen1, []byte(`"golem15.demo"`)) {
|
||||
t.Fatalf("plugins.gen.go missing id:\n%s", gen1)
|
||||
}
|
||||
|
||||
if err := App(ctx, dir, &second); err != nil {
|
||||
t.Fatalf("second build: %v\n%s", err, second.String())
|
||||
}
|
||||
main2, err := os.ReadFile(filepath.Join(dir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen2, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(main1, main2) {
|
||||
t.Fatal("main.go is not byte-stable")
|
||||
}
|
||||
if !bytes.Equal(gen1, gen2) {
|
||||
t.Fatal("plugins.gen.go is not byte-stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPluginRejectsConflictingID(t *testing.T) {
|
||||
dir := copyHelloApp(t)
|
||||
ctx := t.Context()
|
||||
if _, err := MakePlugin(ctx, dir, "golem15.demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := os.ReadFile(filepath.Join(dir, "plugins", "demo", "plugin.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patched := bytes.ReplaceAll(src, []byte(`return "golem15.demo"`), []byte(`return "golem15.hello"`))
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugins", "demo", "plugin.go"), patched, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "demo"))
|
||||
if err == nil || !strings.Contains(err.Error(), "golem15.hello") {
|
||||
t.Fatalf("want conflicting id error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPluginRejectsPathTraversal(t *testing.T) {
|
||||
dir := copyHelloApp(t)
|
||||
outside := t.TempDir()
|
||||
if err := AddPlugin(t.Context(), dir, outside); err == nil {
|
||||
t.Fatal("want outside-root error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsUppercaseID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.yaml")
|
||||
write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: Golem15.Demo\n module: example.com/one\n")
|
||||
if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "vendor.plugin") {
|
||||
t.Fatalf("want id format error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertScaffoldFiles(t *testing.T, pluginDir string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "go.mod")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pluginDir, "config")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"func (p *Plugin) ID()",
|
||||
"func (p *Plugin) Requires()",
|
||||
"func (p *Plugin) Register",
|
||||
"func (p *Plugin) Boot",
|
||||
"party.Register(&Plugin{})",
|
||||
`return "golem15.demo"`,
|
||||
} {
|
||||
if !bytes.Contains(body, []byte(want)) {
|
||||
t.Fatalf("plugin.go missing %s:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
mod, err := os.ReadFile(filepath.Join(pluginDir, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(mod, []byte("toolchain go1.27.0")) {
|
||||
t.Fatalf("plugin go.mod missing toolchain:\n%s", mod)
|
||||
}
|
||||
if !bytes.Contains(mod, []byte("/plugins/demo")) {
|
||||
t.Fatalf("plugin go.mod missing module path:\n%s", mod)
|
||||
}
|
||||
|
||||
var extra []string
|
||||
if err := filepath.WalkDir(pluginDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(pluginDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch rel {
|
||||
case "go.mod", "go.sum", "plugin.go", filepath.Join("config", ".gitkeep"):
|
||||
return nil
|
||||
default:
|
||||
extra = append(extra, rel)
|
||||
return nil
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
t.Fatalf("unexpected scaffold files: %v", extra)
|
||||
}
|
||||
|
||||
cmd := execGo(t, pluginDir, "build", "-o", filepath.Join(pluginDir, "demo.bin"), ".")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("plugin does not compile: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func copyHelloApp(t *testing.T) string {
|
||||
t.Helper()
|
||||
src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
framework, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := t.TempDir()
|
||||
if err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "bin" || strings.HasPrefix(rel, "bin"+string(os.PathSeparator)) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Base(path) == "go.mod" {
|
||||
data = rewriteFrameworkReplace(data, framework)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0o644)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func rewriteFrameworkReplace(data []byte, framework string) []byte {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "replace "+frameworkModule+" =>"):
|
||||
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||
lines[i] = indent + "replace " + frameworkModule + " => " + framework
|
||||
case strings.HasPrefix(trimmed, frameworkModule+" =>"):
|
||||
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||
lines[i] = indent + frameworkModule + " => " + framework
|
||||
}
|
||||
}
|
||||
return []byte(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func execGo(t *testing.T, dir string, args ...string) *exec.Cmd {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), "go", args...)
|
||||
cmd.Dir = dir
|
||||
applyGoWork(cmd, dir)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user