Files
summercms/internal/build/build_test.go
Jakub Zych 8bc18c90b6 feat(build): scaffold working admin controllers with model, AdminFS and delete (quick-261028-pq8)
- make:admin-controller edits the singular of its name (--model overrides):
  ModelName, modelClass and the YAML under models/<model>/
- NewRecord returns &models.<Model>{} when the model exists; otherwise the
  nil body stays and the command says to run make:model
- config_list.yaml offers create and delete with showCheckboxes
- registry.gen.go embeds controllers/*/*.yaml and models/*/*.yaml once they
  exist and exposes generatedAdminFS(); scaffolded plugins implement
  pact.AdminAssets through it, and a plugin without AdminFS gets a note
- blog example moves its YAML to models/post; docs and help text follow
2026-10-28 19:15:23 +01:00

1881 lines
62 KiB
Go

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/<vendor>/<name>, 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/<name> 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/<vendor>/<name> 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)
}
}