package build import ( "bytes" "errors" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" ) func TestLoadHelloManifest(t *testing.T) { m, err := LoadManifest(filepath.Join("..", "..", "examples", "hello", "summer.yaml")) if err != nil { t.Fatal(err) } if m.Binary != "hello" { t.Fatalf("binary = %q", m.Binary) } if len(m.Plugins) != 3 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" || m.Plugins[2].ID != "golem15.optional" { t.Fatalf("plugins = %+v", m.Plugins) } } func TestParseManifestRejectsDuplicatesAndInvalidPaths(t *testing.T) { dir := t.TempDir() t.Run("duplicate id", func(t *testing.T) { path := filepath.Join(dir, "dup.yaml") write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/one\n - id: a.b\n module: example.com/two\n") if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "a.b") { t.Fatalf("want duplicate id error, got %v", err) } }) t.Run("invalid module", func(t *testing.T) { path := filepath.Join(dir, "bad.yaml") write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: \"example.com/one; rm -rf /\"\n") if _, err := LoadManifest(path); err == nil { t.Fatal("want invalid module error") } }) t.Run("path traversal module", func(t *testing.T) { path := filepath.Join(dir, "dotdot.yaml") write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/../evil\n") if _, err := LoadManifest(path); err == nil { t.Fatal("want invalid module error") } }) } func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { m := Manifest{ Module: "example.com/app", Binary: "hello", Plugins: []Plugin{ {ID: "golem15.hello", Module: "example.com/app/plugins/base"}, {ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"}, }, } first, err := generatePluginsGen(m) if err != nil { t.Fatal(err) } second, err := generatePluginsGen(m) if err != nil { t.Fatal(err) } if !bytes.Equal(first, second) { t.Fatal("generated plugins.gen.go is not byte-stable") } src := string(first) baseAt := strings.Index(src, `example.com/app/plugins/base`) greeterAt := strings.Index(src, `example.com/app/plugins/greeter`) if baseAt < 0 || greeterAt < 0 || baseAt > greeterAt { t.Fatalf("imports not in manifest order:\n%s", src) } if !strings.Contains(src, "\t_ \"example.com/app/plugins/base\"") { t.Fatalf("import is not Go-quoted:\n%s", src) } mainSrc, err := generateMain(m) if err != nil { t.Fatal(err) } again, err := generateMain(m) if err != nil { t.Fatal(err) } if !bytes.Equal(mainSrc, again) { t.Fatal("generated main.go is not byte-stable") } if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) { t.Fatalf("main does not use manifest binary:\n%s", mainSrc) } if !bytes.Contains(mainSrc, []byte("lagoon.RuntimeCommands")) { t.Fatalf("main does not register lagoon runtime commands:\n%s", mainSrc) } if !bytes.Contains(mainSrc, []byte("surf.ServeCommand")) { t.Fatalf("main does not register serve:\n%s", mainSrc) } if !bytes.Contains(mainSrc, []byte("surf.RouteListCommand")) { t.Fatalf("main does not register route:list:\n%s", mainSrc) } if bytes.Contains(mainSrc, []byte("examples/hello")) { t.Fatal("generated main hard-codes examples/hello") } } func TestGenerateMainRegistersCabanaRuntimeCommands(t *testing.T) { mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) if err != nil { t.Fatal(err) } if bytes.Count(mainSrc, []byte("cabana.RuntimeCommands")) != 1 { t.Fatalf("cabana.RuntimeCommands count = %d\n%s", bytes.Count(mainSrc, []byte("cabana.RuntimeCommands")), mainSrc) } if !bytes.Contains(mainSrc, []byte("commands = append(commands, cabana.RuntimeCommands(app)...)")) { t.Fatalf("generated main does not append cabana runtime commands once:\n%s", mainSrc) } if bytes.Contains(mainSrc, []byte("examples/hello")) { t.Fatal("generated main hard-codes examples/hello") } } func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) { mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) if err != nil { t.Fatal(err) } line := []byte("commands = append(commands, conga.RuntimeCommands(app, plugins)...)") if n := bytes.Count(mainSrc, line); n != 1 { t.Fatalf("conga.RuntimeCommands line count = %d, want 1\n%s", n, mainSrc) } if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/conga"`)) { t.Fatalf("generated main does not import conga:\n%s", mainSrc) } } // TestGenerateMainInstallsDiary: run first reads the console global options // (bonfire.PeekGlobals), then installs the diary console logger with them, // before config loads (in the --env environment) or any plugin can log; // diary.Setup applies config/log.yaml right after compass loads it and before // the application container exists, and the root re-applies the parsed // options to diary through bonfire.OnGlobals. func TestGenerateMainInstallsDiary(t *testing.T) { mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) if err != nil { t.Fatal(err) } peek := []byte("g := bonfire.PeekGlobals(args)") bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.FromGlobals(g))") open := []byte(`compass.Open(compass.Options{Dir: "config", Env: g.Env})`) setup := []byte("diary.Setup(cfg, diary.FromGlobals(g))") hook := []byte("bonfire.OnGlobals(diary.ApplyGlobals)") for _, want := range [][]byte{peek, bootstrap, open, setup, hook, []byte("os.Exit(run(os.Args[1:], os.Stdout))"), []byte("return bonfire.Execute(context.Background(), root, args)")} { if n := bytes.Count(mainSrc, want); n != 1 { t.Fatalf("%s count = %d, want 1\n%s", want, n, mainSrc) } } if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/diary"`)) { t.Fatalf("generated main does not import diary:\n%s", mainSrc) } if bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) { t.Fatalf("generated main still imports sunscreen:\n%s", mainSrc) } runSig := []byte("func run(args []string, out io.Writer) int {") runStart := bytes.Index(mainSrc, runSig) pk := bytes.Index(mainSrc, peek) boot := bytes.Index(mainSrc, bootstrap) load := bytes.Index(mainSrc, open) set := bytes.Index(mainSrc, setup) newApp := bytes.Index(mainSrc, []byte("backpack.New(")) if runStart < 0 || newApp < 0 || !(runStart < pk && pk < boot && boot < load && load < set && set < newApp) { t.Fatalf("want run { PeekGlobals; Bootstrap; compass.Open; Setup; backpack.New }:\n%s", mainSrc) } if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : pk]); len(between) != 0 { t.Fatalf("statements before PeekGlobals: %q", between) } } // TestGenerateMainPublishesCommandCatalog: the generated main publishes the // final command list as a *bonfire.Catalog after collecting plugin commands // and before building the root, so scheduled runs can call any command. func TestGenerateMainPublishesCommandCatalog(t *testing.T) { mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) if err != nil { t.Fatal(err) } line := []byte("if err := app.Publish(bonfire.NewCatalog(commands)); err != nil {") if n := bytes.Count(mainSrc, line); n != 1 { t.Fatalf("catalog publish count = %d, want 1\n%s", n, mainSrc) } publish := bytes.Index(mainSrc, line) plugins := bytes.Index(mainSrc, []byte("hasCommands.Commands()")) root := bytes.Index(mainSrc, []byte("bonfire.NewRoot(")) if plugins < 0 || root < 0 || !(plugins < publish && publish < root) { t.Fatalf("catalog must be published after plugin commands and before NewRoot:\n%s", mainSrc) } } func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "out.go") content := []byte("package main\n") if err := writeIfChanged(path, content); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if err := writeIfChanged(path, content); err != nil { t.Fatal(err) } again, err := os.Stat(path) if err != nil { t.Fatal(err) } if !info.ModTime().Equal(again.ModTime()) { t.Fatal("identical content rewrote the file") } } 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", "golem15", "demo") if pluginDir != wantDir { t.Fatalf("plugin dir = %s, want %s", pluginDir, wantDir) } assertScaffoldFiles(t, pluginDir) if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil { t.Fatal(err) } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "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/golem15/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/golem15/demo")) { t.Fatalf("go.work missing ./plugins/golem15/demo:\n%s", work) } // plugin:add writes go.work use paths relative to the go.work directory, // so a committed go.work carries no machine-specific path. for _, line := range strings.Split(string(work), "\n") { if strings.HasPrefix(strings.TrimSpace(line), "/") { t.Fatalf("go.work has an absolute use path %q:\n%s", line, 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") } } // TestMakePluginRoutesUnchanged: plain make:plugin still renders the // no-route stub; only summer new opts into the starter route. func TestMakePluginRoutesUnchanged(t *testing.T) { dir := copyHelloApp(t) pluginDir, err := MakePlugin(t.Context(), dir, "golem15.demo") if err != nil { t.Fatal(err) } routes, err := os.ReadFile(filepath.Join(pluginDir, "routes.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(routes, []byte("func (p *Plugin) Routes(pact.Router) error { return nil }")) { t.Fatalf("routes.go is not the no-route stub:\n%s", routes) } for _, unwanted := range []string{`"net/http"`, `"encoding/json"`, "r.Get("} { if bytes.Contains(routes, []byte(unwanted)) { t.Fatalf("plain routes.go contains %s:\n%s", unwanted, routes) } } } 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", "golem15", "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", "golem15", "demo", "plugin.go"), patched, 0o644); err != nil { t.Fatal(err) } err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "golem15", "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 TestParseManifestRejectsDuplicateModule(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "dupmod.yaml") write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/one\n - id: a.c\n module: example.com/one\n") if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "example.com/one") { t.Fatalf("want duplicate module error, got %v", err) } } func TestMakePluginRejectsPathTraversalAndShellMeta(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/../tmp", "golem15.demo;rm", `golem15.demo$(x)`, "golem15.demo`x`", "/tmp.evil", "..evil.plugin", } { if _, err := MakePlugin(t.Context(), dir, id); err == nil { t.Fatalf("id %q: want error", id) } } } func TestBuiltHelloFailsOnInvalidRequires(t *testing.T) { dir := copyHelloApp(t) path := filepath.Join(dir, "plugins", "greeter", "plugin.go") src, err := os.ReadFile(path) if err != nil { t.Fatal(err) } patched := bytes.ReplaceAll(src, []byte(`return []string{"golem15.hello"}`), []byte(`return []string{"golem15.hello", "golem15.missing"}`)) if bytes.Equal(src, patched) { t.Fatal("failed to patch greeter Requires") } if err := os.WriteFile(path, patched, 0o644); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := App(t.Context(), dir, &buf); err != nil { t.Fatalf("build should succeed, got %v\n%s", err, buf.String()) } bin := filepath.Join(dir, "bin", "hello") cmd := exec.CommandContext(t.Context(), bin, "greeter:hello") cmd.Dir = dir out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("expected non-zero exit for missing Requires, output:\n%s", out) } msg := string(out) if !strings.Contains(msg, "golem15.greeter") || !strings.Contains(msg, "golem15.missing") { t.Fatalf("error should name both plugin IDs, got %q", msg) } } 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 TestScaffoldPluginSmoke(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } assertScaffoldFiles(t, pluginDir) pluginSrc, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } regSrc, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } for _, want := range []string{ "generatedModels()", "generatedMigrations()", "generatedCommands(p.app)", "p.app = app", "_ pact.AdminAssets = (*Plugin)(nil)", "func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }", "generatedJobs()", "generatedAdminControllers()", "//go:embed config", "//go:embed lang", "//go:embed views/mail", } { if !bytes.Contains(pluginSrc, []byte(want)) { t.Fatalf("plugin.go missing %s:\n%s", want, pluginSrc) } } for _, want := range []string{ "func generatedModels()", "func generatedMigrations()", "func generatedCommands(app ...*backpack.App) []bonfire.Command", "var generatedAdminTree embed.FS", "func generatedAdminFS() fs.FS", "func generatedJobs()", "func generatedAdminControllers()", } { if !bytes.Contains(regSrc, []byte(want)) { t.Fatalf("registry.gen.go missing %s:\n%s", want, regSrc) } } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := App(ctx, dir, &buf); err != nil { t.Fatalf("app build: %v\n%s", err, buf.String()) } vet := execGo(t, pluginDir, "vet", "./...") if out, err := vet.CombinedOutput(); err != nil { t.Fatalf("plugin go vet: %v\n%s", err, out) } pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(pluginSrc, pluginAfter) { t.Fatal("hand-written plugin.go was rewritten") } regAfter, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(regSrc, regAfter) { t.Fatal("registry.gen.go is not byte-stable after add/build") } } func TestScaffoldCoreArtifacts(t *testing.T) { // One frozen second for make:model and make:migration: the registry must // still list CreateAlbums before AddWidgetIndex. freezeMigrationClock(t, time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC)) dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } pluginBefore, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } model, err := MakeModel(ctx, dir, "golem15.demo", "Album", false) if err != nil { t.Fatal(err) } if model.Hint != "" { t.Fatalf("new plugin printed accessor hint: %s", model.Hint) } if len(model.Files) != 2 { t.Fatalf("make:model files = %v", model.Files) } albumSrc, err := os.ReadFile(filepath.Join(pluginDir, "models", "album.go")) if err != nil { t.Fatal(err) } for _, want := range []string{ "type Album struct", "CreatedAt", "UpdatedAt", `return "golem15_demo_albums"`, } { if !bytes.Contains(albumSrc, []byte(want)) { t.Fatalf("album.go missing %s:\n%s", want, albumSrc) } } createAlbum, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*_create_golem15_demo_albums.go")) if err != nil { t.Fatal(err) } if len(createAlbum) != 1 { t.Fatalf("create-table migration files = %v", createAlbum) } migSrc, err := os.ReadFile(createAlbum[0]) if err != nil { t.Fatal(err) } for _, want := range []string{ "func CreateAlbums()", "CREATE TABLE golem15_demo_albums", "DROP TABLE IF EXISTS golem15_demo_albums", } { if !bytes.Contains(migSrc, []byte(want)) { t.Fatalf("model migration missing %s:\n%s", want, migSrc) } } widget, err := MakeModel(ctx, pluginDir, "", "Widget", true) if err != nil { t.Fatal(err) } if len(widget.Files) != 1 || !strings.HasSuffix(widget.Files[0], filepath.Join("models", "widget.go")) { t.Fatalf("--no-migration files = %v", widget.Files) } widgetMigs, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*widget*")) if err != nil { t.Fatal(err) } if len(widgetMigs) != 0 { t.Fatalf("--no-migration wrote %v", widgetMigs) } if _, err := MakeMigration(ctx, dir, "golem15.demo", "AddWidgetIndex"); err != nil { t.Fatal(err) } if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err != nil { t.Fatal(err) } cmdSrc, err := os.ReadFile(filepath.Join(pluginDir, "console", "purge.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(cmdSrc, []byte(`"demo:purge"`)) { t.Fatalf("command name missing:\n%s", cmdSrc) } if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", false); err == nil { t.Fatal("duplicate make:model want error") } reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err == nil { t.Fatal("duplicate make:command want error") } if err := refreshRegistry(pluginDir); err != nil { t.Fatal(err) } reg2, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(reg1, reg2) { t.Fatal("repeated generation changed registry.gen.go") } for _, want := range []string{ "&models.Album{}", "&models.Widget{}", "updates.CreateAlbums()", "updates.AddWidgetIndex()", "console.PurgeCommand(bootApp)", } { if !bytes.Contains(reg1, []byte(want)) { t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1) } } if create, add := bytes.Index(reg1, []byte("updates.CreateAlbums()")), bytes.Index(reg1, []byte("updates.AddWidgetIndex()")); create > add { t.Fatalf("registry.gen.go lists AddWidgetIndex before CreateAlbums:\n%s", reg1) } pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(pluginBefore, pluginAfter) { t.Fatal("handwritten plugin.go was rewritten") } greeterGo := filepath.Join(dir, "plugins", "greeter", "plugin.go") greeterBefore, err := os.ReadFile(greeterGo) if err != nil { t.Fatal(err) } greeter, err := MakeCommand(ctx, filepath.Join(dir, "plugins", "greeter"), "", "Wave") if err != nil { t.Fatal(err) } if greeter.Hint == "" || !strings.Contains(greeter.Hint, "generatedCommands()") { t.Fatalf("want accessor hint for handwritten plugin, got %q", greeter.Hint) } greeterAfter, err := os.ReadFile(greeterGo) if err != nil { t.Fatal(err) } if !bytes.Equal(greeterBefore, greeterAfter) { t.Fatal("handwritten greeter plugin.go was rewritten") } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := App(ctx, dir, &buf); err != nil { t.Fatalf("app build: %v\n%s", err, buf.String()) } vet := execGo(t, pluginDir, "vet", "./...") if out, err := vet.CombinedOutput(); err != nil { t.Fatalf("plugin go vet: %v\n%s", err, out) } } func TestScaffoldAllArtifacts(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } pluginBefore, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } model, err := MakeModel(ctx, dir, "golem15.demo", "Album", false) if err != nil { t.Fatal(err) } wantModel := filepath.Join(pluginDir, "models", "album.go") if len(model.Files) != 2 || model.Files[0] != wantModel { t.Fatalf("make:model files = %v, want %s plus create-table migration", model.Files, wantModel) } albumSrc, err := os.ReadFile(wantModel) if err != nil { t.Fatal(err) } for _, want := range []string{ "type Album struct", "CreatedAt", "UpdatedAt", `return "golem15_demo_albums"`, } { if !bytes.Contains(albumSrc, []byte(want)) { t.Fatalf("album.go missing %s:\n%s", want, albumSrc) } } createAlbum, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*_create_golem15_demo_albums.go")) if err != nil { t.Fatal(err) } if len(createAlbum) != 1 || createAlbum[0] != model.Files[1] { t.Fatalf("create-table migration files = %v want %v", createAlbum, model.Files[1:]) } migSrc, err := os.ReadFile(createAlbum[0]) if err != nil { t.Fatal(err) } for _, want := range []string{ "ID: \"", "Migrate:", "Rollback:", "CREATE TABLE golem15_demo_albums", "DROP TABLE IF EXISTS golem15_demo_albums", } { if !bytes.Contains(migSrc, []byte(want)) { t.Fatalf("model migration missing %s:\n%s", want, migSrc) } } widget, err := MakeModel(ctx, pluginDir, "", "Widget", true) if err != nil { t.Fatal(err) } if len(widget.Files) != 1 || widget.Files[0] != filepath.Join(pluginDir, "models", "widget.go") { t.Fatalf("--no-migration files = %v", widget.Files) } widgetMigs, err := filepath.Glob(filepath.Join(pluginDir, "updates", "*widget*")) if err != nil { t.Fatal(err) } if len(widgetMigs) != 0 { t.Fatalf("--no-migration wrote %v", widgetMigs) } migration, err := MakeMigration(ctx, dir, "golem15.demo", "AddAlbumIndex") if err != nil { t.Fatal(err) } if len(migration.Files) != 1 || !strings.Contains(migration.Files[0], "add_album_index.go") { t.Fatalf("make:migration files = %v", migration.Files) } command, err := MakeCommand(ctx, dir, "golem15.demo", "Purge") if err != nil { t.Fatal(err) } wantCommand := filepath.Join(pluginDir, "console", "purge.go") if len(command.Files) != 1 || command.Files[0] != wantCommand { t.Fatalf("make:command files = %v, want %s", command.Files, wantCommand) } if command.Hint != "" { t.Fatalf("new plugin printed a make:command hint: %s", command.Hint) } cmdSrc, err := os.ReadFile(wantCommand) if err != nil { t.Fatal(err) } if !bytes.Contains(cmdSrc, []byte("func PurgeCommand(app *backpack.App) bonfire.Command")) { t.Fatalf("purge.go does not take the app:\n%s", cmdSrc) } // Report whether the app arrived, so the built binary proves the wiring. reported := bytes.Replace(cmdSrc, []byte(`out.Printf("demo:purge" + "\n")`), []byte(`out.Printf("demo:purge app=%t\n", app != nil)`), 1) if bytes.Equal(reported, cmdSrc) { t.Fatalf("could not patch purge.go:\n%s", cmdSrc) } write(t, wantCommand, string(reported)) job, err := MakeJob(ctx, dir, "golem15.demo", "Reindex") if err != nil { t.Fatal(err) } wantJob := filepath.Join(pluginDir, "jobs", "reindex.go") if len(job.Files) != 1 || job.Files[0] != wantJob { t.Fatalf("make:job files = %v, want %s", job.Files, wantJob) } jobSrc, err := os.ReadFile(job.Files[0]) if err != nil { t.Fatal(err) } for _, want := range []string{ "func (ReindexArgs) Kind()", `Kind() string { return "golem15.demo.reindex" }`, "func ReindexJob() pact.Job", "conga.Job(func(ctx context.Context, args ReindexArgs) error", `"git.golem15.com/golem15/summercms/modules/conga"`, } { if !bytes.Contains(jobSrc, []byte(want)) { t.Fatalf("job.go missing %s:\n%s", want, jobSrc) } } if bytes.Contains(bytes.ToLower(jobSrc), []byte("river")) { t.Fatal("job stub imports River") } admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "") if err != nil { t.Fatal(err) } wantAdmin := []string{ filepath.Join(pluginDir, "controllers", "albums.go"), filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"), filepath.Join(pluginDir, "models", "album", "fields.yaml"), filepath.Join(pluginDir, "models", "album", "columns.yaml"), } if len(admin.Files) != len(wantAdmin) { t.Fatalf("make:admin-controller files = %v", admin.Files) } for i, want := range wantAdmin { if admin.Files[i] != want { t.Fatalf("admin file[%d] = %s, want %s", i, admin.Files[i], want) } } adminSrc, err := os.ReadFile(wantAdmin[0]) if err != nil { t.Fatal(err) } for _, want := range []string{ `ID() string { return "golem15.demo.albums" }`, `ModelName() string { return "Album" }`, `ConfigDir() string { return "controllers/albums" }`, // WR-09: the scaffold is never open to every administrator and names // its record source, so completing it cannot silently expose the data. `pact.AdminPermissioned = albumsAdmin{}`, `RequiredPermissions() []string`, `return []string{"golem15.demo.access_albums"}`, // make:model Album ran first, so NewRecord returns it. `NewRecord() any { return &models.Album{} }`, `"` + filepath.ToSlash(mustModule(t, pluginDir)) + `/models"`, } { if !bytes.Contains(adminSrc, []byte(want)) { t.Fatalf("albums.go missing %s:\n%s", want, adminSrc) } } if strings.Contains(admin.Hint, "NewRecord returns nil") { t.Fatalf("hint names a missing model although models.Album exists: %s", admin.Hint) } for _, rel := range []string{ filepath.Join("models", "album", "fields.yaml"), filepath.Join("models", "album", "columns.yaml"), } { body, err := os.ReadFile(filepath.Join(pluginDir, rel)) if err != nil { t.Fatal(err) } key := "fields:" if strings.Contains(rel, "columns") { key = "columns:" } if !bytes.Contains(body, []byte(key)) { t.Fatalf("%s missing %s:\n%s", rel, key, body) } } for path, kind := range map[string]string{ wantModel: "model", model.Files[1]: "migration", wantCommand: "command", wantJob: "job", wantAdmin[0]: "admin-controller", } { body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if want := "//summer:make " + kind + "\n\npackage "; !bytes.HasPrefix(body, []byte(want)) { t.Fatalf("%s does not start with %q:\n%s", path, want, body) } if bytes.Contains(body, []byte("DO NOT EDIT")) { t.Fatalf("%s still says DO NOT EDIT:\n%s", path, body) } } reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.HasPrefix(reg1, []byte("// Code generated by summer make. DO NOT EDIT.\n")) { t.Fatalf("registry.gen.go lost its generated-code header:\n%s", reg1) } for _, want := range []string{ "&models.Album{}", "&models.Widget{}", "updates.CreateAlbums()", "updates.AddAlbumIndex()", "console.PurgeCommand(bootApp)", "jobs.ReindexJob()", "controllers.AlbumsController()", } { if !bytes.Contains(reg1, []byte(want)) { t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1) } } if _, err := MakeJob(ctx, dir, "golem15.demo", "Reindex"); err == nil { t.Fatal("duplicate make:job want error") } if err := refreshRegistry(pluginDir); err != nil { t.Fatal(err) } reg2, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(reg1, reg2) { t.Fatal("repeated generation changed registry.gen.go") } pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(pluginBefore, pluginAfter) { t.Fatal("handwritten plugin.go was rewritten") } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil { t.Fatal(err) } var buf bytes.Buffer if err := App(ctx, dir, &buf); err != nil { t.Fatalf("app build: %v\n%s", err, buf.String()) } run := exec.CommandContext(ctx, filepath.Join(dir, "bin", "hello"), "demo:purge") run.Dir = dir if out, err := run.CombinedOutput(); err != nil || !bytes.Contains(out, []byte("demo:purge app=true")) { t.Fatalf("built app demo:purge: %v\n%s", err, out) } if out, err := execGo(t, dir, "build", "./...").CombinedOutput(); err != nil { t.Fatalf("copied workspace go build: %v\n%s", err, out) } if out, err := execGo(t, dir, "vet", "./...").CombinedOutput(); err != nil { t.Fatalf("copied workspace go vet: %v\n%s", err, out) } if out, err := execGo(t, pluginDir, "vet", "./...").CombinedOutput(); err != nil { t.Fatalf("plugin go vet: %v\n%s", err, out) } } func TestModelsLeaf(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil { t.Fatal(err) } modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod")) if err != nil { t.Fatal(err) } bad := "package models\n\nimport " + strconv.Quote(modPath+"/classes") + "\n" if err := os.WriteFile(filepath.Join(pluginDir, "models", "bad.go"), []byte(bad), 0o644); err != nil { t.Fatal(err) } var buf bytes.Buffer err = App(ctx, dir, &buf) if err == nil { t.Fatal("want models leaf import error") } msg := err.Error() for _, want := range []string{"golem15.demo", "models/bad.go", modPath + "/classes"} { if !strings.Contains(msg, want) { t.Fatalf("leaf error %q missing %s", msg, want) } } } func TestMakeArtifactRejectsMalformedNames(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() if _, err := MakePlugin(ctx, dir, "golem15.demo"); err != nil { t.Fatal(err) } for _, name := range []string{"", "album", "../Evil", "Album;rm", `Album$(x)`, "Album/tmp", "2Album", "Album name"} { if _, err := MakeModel(ctx, dir, "golem15.demo", name, true); err == nil { t.Fatalf("make:model %q: want error", name) } if _, err := MakeMigration(ctx, dir, "golem15.demo", name); err == nil { t.Fatalf("make:migration %q: want error", name) } if _, err := MakeCommand(ctx, dir, "golem15.demo", name); err == nil { t.Fatalf("make:command %q: want error", name) } if _, err := MakeJob(ctx, dir, "golem15.demo", name); err == nil { t.Fatalf("make:job %q: want error", name) } if _, err := MakeAdminController(ctx, dir, "golem15.demo", name, ""); err == nil { t.Fatalf("make:admin-controller %q: want error", name) } } for _, id := range []string{"golem15.demo/../tmp", "Golem15.Demo", "../evil.plugin", "golem15.demo;rm"} { if _, err := MakeModel(ctx, dir, id, "Album", true); err == nil { t.Fatalf("make:model plugin %q: want error", id) } } if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", true); err != nil { t.Fatal(err) } if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", true); err == nil { t.Fatal("duplicate make:model want error") } } func TestMakeAdminControllerLayout(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } pluginBefore, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } t.Run("duplicate asset writes nothing", func(t *testing.T) { kept := filepath.Join(pluginDir, "models", "genre", "fields.yaml") if err := os.MkdirAll(filepath.Dir(kept), 0o755); err != nil { t.Fatal(err) } const original = "fields: {}\n" if err := os.WriteFile(kept, []byte(original), 0o644); err != nil { t.Fatal(err) } if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Genres", ""); err == nil { t.Fatal("duplicate model asset want error") } body, err := os.ReadFile(kept) if err != nil || string(body) != original { t.Fatalf("existing fields.yaml changed: %v %q", err, body) } for _, rel := range []string{ filepath.Join("controllers", "genres.go"), filepath.Join("controllers", "genres", "config_form.yaml"), filepath.Join("controllers", "genres", "config_list.yaml"), filepath.Join("models", "genre", "columns.yaml"), } { if _, err := os.Stat(filepath.Join(pluginDir, rel)); !os.IsNotExist(err) { t.Fatalf("partial scaffold file %s err=%v", rel, err) } } }) t.Run("winter files", func(t *testing.T) { admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "") if err != nil { t.Fatal(err) } want := []string{ filepath.Join(pluginDir, "controllers", "albums.go"), filepath.Join(pluginDir, "controllers", "albums", "config_form.yaml"), filepath.Join(pluginDir, "controllers", "albums", "config_list.yaml"), filepath.Join(pluginDir, "models", "album", "fields.yaml"), filepath.Join(pluginDir, "models", "album", "columns.yaml"), } if strings.Join(admin.Files, "\n") != strings.Join(want, "\n") { t.Fatalf("files =\n%s\nwant\n%s", strings.Join(admin.Files, "\n"), strings.Join(want, "\n")) } if !strings.Contains(admin.Hint, "run summer make:model golem15.demo Album") || !strings.Contains(admin.Hint, "&models.Album{}") { t.Fatalf("hint does not explain the missing model: %q", admin.Hint) } if strings.Contains(admin.Hint, "generatedAdminFS") { t.Fatalf("scaffolded plugin got an AdminFS hint: %q", admin.Hint) } for _, rel := range []string{ filepath.Join("controllers", "albums", "fields.yaml"), filepath.Join("controllers", "albums", "columns.yaml"), } { if _, err := os.Stat(filepath.Join(pluginDir, rel)); !os.IsNotExist(err) { t.Fatalf("old controller-local asset %s still exists", rel) } } goSrc, err := os.ReadFile(want[0]) if err != nil { t.Fatal(err) } for _, text := range []string{ `ID() string { return "golem15.demo.albums" }`, `ModelName() string { return "Album" }`, `ConfigDir() string { return "controllers/albums" }`, `NewRecord() any { return nil }`, "TODO: run summer make:model for Album", } { if !bytes.Contains(goSrc, []byte(text)) { t.Fatalf("albums.go missing %s:\n%s", text, goSrc) } } formSrc, err := os.ReadFile(want[1]) if err != nil { t.Fatal(err) } for _, text := range []string{ "name: Album", "modelClass: Album", "form: ~/plugins/golem15/demo/models/album/fields.yaml", } { if !bytes.Contains(formSrc, []byte(text)) { t.Fatalf("config_form.yaml missing %s:\n%s", text, formSrc) } } listSrc, err := os.ReadFile(want[2]) if err != nil { t.Fatal(err) } for _, text := range []string{ "modelClass: Album", "title: Albums", "list: ~/plugins/golem15/demo/models/album/columns.yaml", "showCheckboxes: true", "buttons: [create, delete]", "prompt: backend::lang.list.search_prompt", } { if !bytes.Contains(listSrc, []byte(text)) { t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc) } } fsys := os.DirFS(pluginDir) ctl := scaffoldAlbumsController{} if _, err := cabana.CompileForm("golem15.demo", ctl, fsys); err != nil { t.Fatalf("strict form compile: %v", err) } if _, err := cabana.CompileList("golem15.demo", ctl, fsys); err != nil { t.Fatalf("strict list compile: %v", err) } reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(reg1, []byte("controllers.AlbumsController()")) { t.Fatalf("registry missing albums controller:\n%s", reg1) } if err := refreshRegistry(pluginDir); err != nil { t.Fatal(err) } reg2, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(reg1, reg2) { t.Fatal("repeated registry generation changed bytes") } if _, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", ""); err == nil { t.Fatal("duplicate controller want error") } reg3, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(reg1, reg3) { t.Fatal("failed duplicate scaffold changed registry.gen.go") } pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(pluginBefore, pluginAfter) { t.Fatal("handwritten plugin.go was rewritten") } }) } type scaffoldAlbumsController struct{} func (scaffoldAlbumsController) ID() string { return "golem15.demo.albums" } func (scaffoldAlbumsController) ModelName() string { return "Album" } func (scaffoldAlbumsController) ConfigDir() string { return "controllers/albums" } var _ pact.AdminController = scaffoldAlbumsController{} func assertScaffoldFiles(t *testing.T, pluginDir string) { t.Helper() assertWinterPluginShape(t, pluginDir) 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/golem15/demo")) { t.Fatalf("plugin go.mod missing module path:\n%s", mod) } cmd := execGo(t, pluginDir, "build", "./...") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("plugin does not compile: %v\n%s", err, out) } } func assertWinterPluginShape(t *testing.T, pluginDir string) { t.Helper() required := []string{ "go.mod", "plugin.go", "routes.go", "registry.gen.go", filepath.Join("config", "config.yaml"), filepath.Join("lang", "en", "lang.yaml"), filepath.Join("views", "mail", "welcome.htm"), } for _, leaf := range pluginLeaves { required = append(required, filepath.Join(leaf, "doc.go")) } allowed := map[string]struct{}{ "go.sum": {}, } for _, rel := range required { allowed[rel] = struct{}{} if _, err := os.Stat(filepath.Join(pluginDir, rel)); err != nil { t.Fatalf("missing %s: %v", rel, err) } } 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 } if _, ok := allowed[rel]; ok { return nil } if strings.HasPrefix(filepath.Base(rel), "registry.gen.go.") { extra = append(extra, rel) return nil } extra = append(extra, rel) return nil }); err != nil { t.Fatal(err) } if len(extra) > 0 { t.Fatalf("unexpected scaffold files: %v", extra) } for _, asset := range []string{ filepath.Join("config", "config.yaml"), filepath.Join("lang", "en", "lang.yaml"), filepath.Join("views", "mail", "welcome.htm"), } { if strings.HasPrefix(filepath.Base(asset), ".") || strings.HasPrefix(filepath.Base(asset), "_") { t.Fatalf("embed-incompatible hidden asset %s", asset) } } } 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 { t.Fatal(err) } } // freezeMigrationClock pins migrationNow to at for the rest of the test. func freezeMigrationClock(t *testing.T, at time.Time) { t.Helper() prev := migrationNow migrationNow = func() time.Time { return at } t.Cleanup(func() { migrationNow = prev }) } // TestNextMigrationFileSortsAfterExisting: a new migration's timestamp is // later than every migration already in updates/, so file-name order is // creation order even within one second. func TestNextMigrationFileSortsAfterExisting(t *testing.T) { now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) t.Run("same second", func(t *testing.T) { freezeMigrationClock(t, now) dir := t.TempDir() firstID, firstRel, err := nextMigrationFile(dir, "create_widgets") if err != nil { t.Fatal(err) } if firstID != "20261028120000_create_widgets" { t.Fatalf("first id = %s", firstID) } write(t, filepath.Join(dir, firstRel), "package updates\n") secondID, secondRel, err := nextMigrationFile(dir, "add_index") if err != nil { t.Fatal(err) } if secondID != "20261028120001_add_index" { t.Fatalf("second id = %s", secondID) } if !(firstRel < secondRel) { t.Fatalf("%s does not sort before %s", firstRel, secondRel) } }) t.Run("existing later than now", func(t *testing.T) { freezeMigrationClock(t, now) dir := t.TempDir() updates := filepath.Join(dir, "updates") if err := os.MkdirAll(updates, 0o755); err != nil { t.Fatal(err) } write(t, filepath.Join(updates, "20990101000000_x.go"), "package updates\n") write(t, filepath.Join(updates, "20200101000000_old.go"), "package updates\n") id, _, err := nextMigrationFile(dir, "next") if err != nil { t.Fatal(err) } if id != "20990101000001_next" { t.Fatalf("id = %s, want 20990101000001_next", id) } }) t.Run("non-migration files ignored", func(t *testing.T) { freezeMigrationClock(t, now) dir := t.TempDir() updates := filepath.Join(dir, "updates") if err := os.MkdirAll(updates, 0o755); err != nil { t.Fatal(err) } for _, name := range []string{"doc.go", "99990101000000x.go", "migrations_test.go", "20990101000000_notes.txt", "2099010100000a_bad.go"} { write(t, filepath.Join(updates, name), "package updates\n") } id, _, err := nextMigrationFile(dir, "plain") if err != nil { t.Fatal(err) } if id != "20261028120000_plain" { t.Fatalf("id = %s, want the frozen now", id) } }) t.Run("older existing keeps now", func(t *testing.T) { freezeMigrationClock(t, now) dir := t.TempDir() updates := filepath.Join(dir, "updates") if err := os.MkdirAll(updates, 0o755); err != nil { t.Fatal(err) } write(t, filepath.Join(updates, "20200101000000_old.go"), "package updates\n") id, _, err := nextMigrationFile(dir, "plain") if err != nil { t.Fatal(err) } if id != "20261028120000_plain" { t.Fatalf("id = %s, want the frozen now", id) } }) } // TestMakePluginVendorDirs: make:plugin writes plugins//, so two // vendors' plugins with the same name live side by side and both build. func TestMakePluginVendorDirs(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() m, err := LoadManifest(filepath.Join(dir, "summer.yaml")) if err != nil { t.Fatal(err) } for _, tc := range []struct{ id, vendor string }{ {"acme.blog", "acme"}, {"pixelpixel.blog", "pixelpixel"}, } { pluginDir, err := MakePlugin(ctx, dir, tc.id) if err != nil { t.Fatalf("%s: %v", tc.id, err) } if want := filepath.Join(dir, "plugins", tc.vendor, "blog"); pluginDir != want { t.Fatalf("%s dir = %s, want %s", tc.id, pluginDir, want) } mod, err := readModulePath(filepath.Join(pluginDir, "go.mod")) if err != nil { t.Fatal(err) } if want := m.Module + "/plugins/" + tc.vendor + "/blog"; mod != want { t.Fatalf("%s module = %s, want %s", tc.id, mod, want) } if pkg, err := packageNameOf(filepath.Join(pluginDir, "plugin.go")); err != nil || pkg != "blog" { t.Fatalf("%s package = %q, %v", tc.id, pkg, err) } if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil { t.Fatalf("%s does not build: %v\n%s", tc.id, err, out) } } _, err = MakePlugin(ctx, dir, "acme.blog") if err == nil { t.Fatal("second make:plugin acme.blog want error") } var hint *bonfire.HintError if !errors.As(err, &hint) || !strings.Contains(hint.Try, "plugin:add plugins/acme/blog") { t.Fatalf("existing-directory hint does not name plugins/acme/blog: %#v", hint) } // examples/hello keeps plugins/greeter in the older layout: a vendor // named greeter would nest a module inside it. if _, err := MakePlugin(ctx, dir, "greeter.extra"); err == nil || !strings.Contains(err.Error(), "not a vendor directory") { t.Fatalf("make:plugin inside a legacy plugin module: %v", err) } } // TestLocatePluginLegacyLayout: a plugin in the older plugins/ layout, // not listed in summer.yaml, is still found by the make: commands. func TestLocatePluginLegacyLayout(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } legacy := filepath.Join(dir, "plugins", "demo") if err := os.Rename(pluginDir, legacy); err != nil { t.Fatal(err) } got, err := locatePluginDir(dir, "golem15.demo") if err != nil || got != legacy { t.Fatalf("locatePluginDir = %q, %v; want %s", got, err, legacy) } res, err := MakeModel(ctx, dir, "golem15.demo", "Album", true) if err != nil { t.Fatal(err) } if res.PluginDir != legacy { t.Fatalf("make:model plugin dir = %s, want %s", res.PluginDir, legacy) } if _, err := os.Stat(filepath.Join(legacy, "models", "album.go")); err != nil { t.Fatal(err) } if _, err := MakePlugin(ctx, dir, "golem15.demo"); err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("make:plugin over a legacy copy: %v", err) } // With both layouts present, plugins// wins. vendorDir := filepath.Join(dir, "plugins", "golem15", "demo") if err := os.MkdirAll(vendorDir, 0o755); err != nil { t.Fatal(err) } if got, err := locatePluginDir(dir, "golem15.demo"); err != nil || got != vendorDir { t.Fatalf("locatePluginDir with both layouts = %q, %v; want %s", got, err, vendorDir) } } // TestRegistryScanMarkersAndAppCommands: registry.gen.go lists files that // carry the //summer:make directive or the older generated-code header, and // a command function may take exactly one *backpack.App. func TestRegistryScanMarkersAndAppCommands(t *testing.T) { dir := t.TempDir() const imports = "import (\n\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n\t\"git.golem15.com/golem15/summercms/modules/bonfire\"\n\t\"gorm.io/gorm\"\n)\n\n" + "var (\n\t_ backpack.App\n\t_ gorm.DB\n)\n\n" files := map[string]string{ "legacy.go": "// Code generated by summer make. DO NOT EDIT.\n\npackage console\n\n" + imports + "func LegacyCommand() bonfire.Command { return bonfire.Command{} }\n", "unmarked.go": "package console\n\n" + imports + "func UnmarkedCommand() bonfire.Command { return bonfire.Command{} }\n", "lookalike.go": "//summer:makefile command\n\npackage console\n\n" + imports + "func LookalikeCommand() bonfire.Command { return bonfire.Command{} }\n", "bare.go": "//summer:make\n\npackage console\n\n" + imports + "func BareCommand() bonfire.Command { return bonfire.Command{} }\n", "named.go": "//summer:make command\n\npackage console\n\n" + imports + "func NamedCommand(app *backpack.App) bonfire.Command { return bonfire.Command{} }\n", "unnamed.go": "//summer:make command\n\npackage console\n\n" + imports + "func UnnamedCommand(*backpack.App) bonfire.Command { return bonfire.Command{} }\n", "value.go": "//summer:make command\n\npackage console\n\n" + imports + "func ValueCommand(app backpack.App) bonfire.Command { return bonfire.Command{} }\n", "two.go": "//summer:make command\n\npackage console\n\n" + imports + "func TwoCommand(app *backpack.App, n int) bonfire.Command { return bonfire.Command{} }\n", "twonames.go": "//summer:make command\n\npackage console\n\n" + imports + "func TwoNamesCommand(a, b *backpack.App) bonfire.Command { return bonfire.Command{} }\n", "other.go": "//summer:make command\n\npackage console\n\n" + imports + "func OtherCommand(db *gorm.DB) bonfire.Command { return bonfire.Command{} }\n", } for name, body := range files { write(t, filepath.Join(dir, name), body) } refs, err := scanGeneratedIdents(dir, "console", scanCommandFuncs) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, ref := range refs { got[ref.Ident] = ref.TakesApp } want := map[string]bool{ "BareCommand": false, "LegacyCommand": false, "NamedCommand": true, "UnnamedCommand": true, } if len(got) != len(want) { t.Fatalf("refs = %+v, want %v", refs, want) } for ident, takesApp := range want { if v, ok := got[ident]; !ok || v != takesApp { t.Fatalf("refs = %+v, want %s TakesApp=%t", refs, ident, takesApp) } } // Other kinds still take no parameters at all. jobsDir := t.TempDir() write(t, filepath.Join(jobsDir, "job.go"), "//summer:make job\n\npackage jobs\n\nimport (\n\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n\t\"git.golem15.com/golem15/summercms/modules/pact\"\n)\n\n"+ "func AppJob(app *backpack.App) pact.Job { return nil }\n\nfunc PlainJob() pact.Job { return nil }\n") jobRefs, err := scanGeneratedIdents(jobsDir, "jobs", scanJobFuncs) if err != nil { t.Fatal(err) } if len(jobRefs) != 1 || jobRefs[0].Ident != "PlainJob" { t.Fatalf("job refs = %+v, want only PlainJob", jobRefs) } sortRefs(refs) data := registryData{Package: "demo", Module: "example.com/app/plugins/acme/demo", Commands: refs} data.prepare() src, err := renderStub("registry.go", data) if err != nil { t.Fatal(err) } for _, line := range []string{ "func generatedCommands(app ...*backpack.App) []bonfire.Command {", "var bootApp *backpack.App", "console.BareCommand(),", "console.LegacyCommand(),", "console.NamedCommand(bootApp),", "console.UnnamedCommand(bootApp),", } { if !bytes.Contains(src, []byte(line)) { t.Fatalf("registry.gen.go missing %s:\n%s", line, src) } } // Without app-taking commands the local is not declared. data = registryData{Package: "demo", Module: "example.com/app/plugins/acme/demo", Commands: []registryRef{{Package: "console", Ident: "LegacyCommand"}}} data.prepare() src, err = renderStub("registry.go", data) if err != nil { t.Fatal(err) } if bytes.Contains(src, []byte("bootApp")) { t.Fatalf("registry.gen.go declares bootApp without app-taking commands:\n%s", src) } } // TestMakeCommandLegacyPluginGo: a plugin.go in the older shape (no app // kept, generatedCommands() without arguments) still compiles after // make:command, and the command prints how to pass the app. func TestMakeCommandLegacyPluginGo(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } pluginGo := filepath.Join(pluginDir, "plugin.go") src, err := os.ReadFile(pluginGo) if err != nil { t.Fatal(err) } old := string(src) for _, r := range []struct{ from, to string }{ {"type Plugin struct {\n\t// app is the application, kept from Boot for the generated commands.\n\tapp *backpack.App\n}", "type Plugin struct{}"}, {"func (p *Plugin) Boot(app *backpack.App) error {\n\tp.app = app\n\treturn nil\n}", "func (p *Plugin) Boot(*backpack.App) error { return nil }"}, {"generatedCommands(p.app)", "generatedCommands( )"}, } { if !strings.Contains(old, r.from) { t.Fatalf("plugin.go lacks %q:\n%s", r.from, old) } old = strings.Replace(old, r.from, r.to, 1) } write(t, pluginGo, old) res, err := MakeCommand(ctx, dir, "golem15.demo", "Purge") if err != nil { t.Fatal(err) } for _, want := range []string{"console.PurgeCommand receives a nil *backpack.App", "generatedCommands(p.app)"} { if !strings.Contains(res.Hint, want) { t.Fatalf("hint %q lacks %q", res.Hint, want) } } if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil { t.Fatalf("legacy plugin.go does not build: %v\n%s", err, out) } if out, err := execGo(t, pluginDir, "vet", "./...").CombinedOutput(); err != nil { t.Fatalf("legacy plugin.go vet: %v\n%s", err, out) } } func mustModule(t *testing.T, pluginDir string) string { t.Helper() mod, err := readModulePath(filepath.Join(pluginDir, "go.mod")) if err != nil { t.Fatal(err) } return mod } func TestSingularize(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"Posts", "Post"}, {"Albums", "Album"}, {"BlogPosts", "BlogPost"}, {"Categories", "Category"}, {"Boxes", "Box"}, {"Matches", "Match"}, {"Wishes", "Wish"}, {"Addresses", "Address"}, {"Genres", "Genre"}, {"Status", "Status"}, {"Statuses", "Status"}, {"Post", "Post"}, {"Houses", "House"}, {"Courses", "Course"}, {"Quizzes", "Quiz"}, {"Analysis", "Analysis"}, {"Days", "Day"}, } { if got := singularize(tc.in); got != tc.want { t.Errorf("singularize(%q) = %q, want %q", tc.in, got, tc.want) } } for _, w := range []string{"post", "album", "category", "box", "match", "wish", "address", "genre", "status", "bus", "analysis", "day", "key", "buzz", "tax", "branch", "dish", "class", "house", "cause", "course", "index"} { if got := singularize(pluralize(w)); got != w { t.Errorf("singularize(pluralize(%q)) = singularize(%q) = %q", w, pluralize(w), got) } } } // TestMakeAdminControllerModel: --model overrides the singular, an invalid // model writes nothing, and a plugin without AdminFS gets a hint. func TestMakeAdminControllerModel(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } if _, err := MakeAdminController(ctx, dir, "golem15.demo", "People", "person"); err == nil { t.Fatal("lower-case model want error") } for _, rel := range []string{"controllers/people.go", "controllers/people", "models/person", "models/people"} { if _, err := os.Stat(filepath.Join(pluginDir, filepath.FromSlash(rel))); !os.IsNotExist(err) { t.Fatalf("invalid model left %s: %v", rel, err) } } res, err := MakeAdminController(ctx, dir, "golem15.demo", "People", "Person") if err != nil { t.Fatal(err) } want := []string{ filepath.Join(pluginDir, "controllers", "people.go"), filepath.Join(pluginDir, "controllers", "people", "config_form.yaml"), filepath.Join(pluginDir, "controllers", "people", "config_list.yaml"), filepath.Join(pluginDir, "models", "person", "fields.yaml"), filepath.Join(pluginDir, "models", "person", "columns.yaml"), } if strings.Join(res.Files, "\n") != strings.Join(want, "\n") { t.Fatalf("files =\n%s\nwant\n%s", strings.Join(res.Files, "\n"), strings.Join(want, "\n")) } for path, texts := range map[string][]string{ want[0]: {`ModelName() string { return "Person" }`, `ID() string { return "golem15.demo.people" }`, `return []string{"golem15.demo.access_people"}`}, want[1]: {"modelClass: Person", "form: ~/plugins/golem15/demo/models/person/fields.yaml", "defaultRedirect: golem15/demo/people"}, want[2]: {"modelClass: Person", "title: People", "list: ~/plugins/golem15/demo/models/person/columns.yaml"}, } { body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } for _, text := range texts { if !bytes.Contains(body, []byte(text)) { t.Fatalf("%s missing %s:\n%s", path, text, body) } } } // The handwritten greeter plugin declares no AdminFS. greeter, err := MakeAdminController(ctx, filepath.Join(dir, "plugins", "greeter"), "", "Waves", "") if err != nil { t.Fatal(err) } for _, text := range []string{"generatedAdminFS()", "pact.AdminAssets"} { if !strings.Contains(greeter.Hint, text) { t.Fatalf("greeter hint %q lacks %s", greeter.Hint, text) } } } // TestScaffoldAdminFSEndToEnd: a scaffolded plugin serves its admin YAML // through AdminFS from registry.gen.go: an empty tree before the first // controller, the controller and model YAML after it, and NewRecord returns // the model make:model wrote. func TestScaffoldAdminFSEndToEnd(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") if err != nil { t.Fatal(err) } reg, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if bytes.Contains(reg, []byte("//go:embed")) || !bytes.Contains(reg, []byte("func generatedAdminFS() fs.FS")) { t.Fatalf("fresh registry.gen.go:\n%s", reg) } probe := filepath.Join(pluginDir, "adminfs_probe_test.go") runProbe := func(body string) { t.Helper() write(t, probe, "package demo\n\nimport (\n\t\"io/fs\"\n\t\"testing\"\n\n\t\"git.golem15.com/golem15/summercms/modules/pact\"\n)\n\nvar _ = fs.ReadFile\n\nfunc TestAdminFSProbe(t *testing.T) {\n\tvar p any = &Plugin{}\n\tassets, ok := p.(pact.AdminAssets)\n\tif !ok || assets.AdminFS() == nil {\n\t\tt.Fatal(\"no AdminFS\")\n\t}\n\tfsys := assets.AdminFS()\n"+body+"}\n") if out, err := execGo(t, pluginDir, "test", "-count=1", "-run", "TestAdminFSProbe", ".").CombinedOutput(); err != nil { t.Fatalf("probe: %v\n%s", err, out) } } runProbe("\tentries, err := fs.ReadDir(fsys, \".\")\n\tif err != nil || len(entries) != 0 {\n\t\tt.Fatalf(\"empty tree: %v %v\", entries, err)\n\t}\n") if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", true); err != nil { t.Fatal(err) } res, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums", "") if err != nil { t.Fatal(err) } if res.Hint != "" { t.Fatalf("scaffolded plugin with the model printed a hint: %s", res.Hint) } goSrc, err := os.ReadFile(filepath.Join(pluginDir, "controllers", "albums.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(goSrc, []byte("NewRecord() any { return &models.Album{} }")) { t.Fatalf("albums.go NewRecord:\n%s", goSrc) } reg, err = os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(reg, []byte("//go:embed controllers/*/*.yaml models/*/*.yaml\nvar generatedAdminTree embed.FS")) { t.Fatalf("registry.gen.go does not embed the admin YAML:\n%s", reg) } if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil { t.Fatalf("plugin build: %v\n%s", err, out) } runProbe("\tfor _, name := range []string{\"controllers/albums/config_form.yaml\", \"controllers/albums/config_list.yaml\", \"models/album/fields.yaml\", \"models/album/columns.yaml\"} {\n\t\tif _, err := fs.ReadFile(fsys, name); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif _, err := fs.Stat(fsys, \"controllers/albums.go\"); err == nil {\n\t\tt.Fatal(\"Go source embedded\")\n\t}\n") if err := os.Remove(probe); err != nil { t.Fatal(err) } }