package build import ( "bytes" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" ) 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("examples/hello")) { t.Fatal("generated main hard-codes examples/hello") } } 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", "demo") if pluginDir != wantDir { t.Fatalf("plugin dir = %s, want %s", pluginDir, wantDir) } assertScaffoldFiles(t, pluginDir) if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil { t.Fatal(err) } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil { t.Fatalf("repeat plugin:add: %v", err) } m, err := LoadManifest(filepath.Join(dir, "summer.yaml")) if err != nil { t.Fatal(err) } if len(m.Plugins) != 4 { t.Fatalf("plugins = %+v", m.Plugins) } last := m.Plugins[len(m.Plugins)-1] if last.ID != "golem15.demo" { t.Fatalf("last plugin id = %q", last.ID) } if last.Module != m.Module+"/plugins/demo" { t.Fatalf("last plugin module = %q", last.Module) } demoCount := 0 for _, p := range m.Plugins { if p.ID == "golem15.demo" { demoCount++ } } if demoCount != 1 { t.Fatalf("golem15.demo count = %d", demoCount) } work, err := os.ReadFile(filepath.Join(dir, "go.work")) if err != nil { t.Fatal(err) } if !bytes.Contains(work, []byte("plugins/demo")) { t.Fatalf("go.work missing plugins/demo:\n%s", work) } appMod, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil { t.Fatal(err) } if !bytes.Contains(appMod, []byte(last.Module)) { t.Fatalf("app go.mod missing %s:\n%s", last.Module, appMod) } var first, second bytes.Buffer if err := App(ctx, dir, &first); err != nil { t.Fatalf("first build: %v\n%s", err, first.String()) } if !strings.Contains(first.String(), "built hello in ") { t.Fatalf("missing elapsed build time:\n%s", first.String()) } main1, err := os.ReadFile(filepath.Join(dir, "main.go")) if err != nil { t.Fatal(err) } gen1, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(gen1, []byte(last.Module)) { t.Fatalf("plugins.gen.go missing %s:\n%s", last.Module, gen1) } if !bytes.Contains(gen1, []byte(`"golem15.demo"`)) { t.Fatalf("plugins.gen.go missing id:\n%s", gen1) } if err := App(ctx, dir, &second); err != nil { t.Fatalf("second build: %v\n%s", err, second.String()) } main2, err := os.ReadFile(filepath.Join(dir, "main.go")) if err != nil { t.Fatal(err) } gen2, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Equal(main1, main2) { t.Fatal("main.go is not byte-stable") } if !bytes.Equal(gen1, gen2) { t.Fatal("plugins.gen.go is not byte-stable") } } func TestAddPluginRejectsConflictingID(t *testing.T) { dir := copyHelloApp(t) ctx := t.Context() if _, err := MakePlugin(ctx, dir, "golem15.demo"); err != nil { t.Fatal(err) } src, err := os.ReadFile(filepath.Join(dir, "plugins", "demo", "plugin.go")) if err != nil { t.Fatal(err) } patched := bytes.ReplaceAll(src, []byte(`return "golem15.demo"`), []byte(`return "golem15.hello"`)) if err := os.WriteFile(filepath.Join(dir, "plugins", "demo", "plugin.go"), patched, 0o644); err != nil { t.Fatal(err) } err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "demo")) if err == nil || !strings.Contains(err.Error(), "golem15.hello") { t.Fatalf("want conflicting id error, got %v", err) } } func TestAddPluginRejectsPathTraversal(t *testing.T) { dir := copyHelloApp(t) outside := t.TempDir() if err := AddPlugin(t.Context(), dir, outside); err == nil { t.Fatal("want outside-root error") } } func 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()", "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()", "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", "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) { 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()", } { if !bytes.Contains(reg1, []byte(want)) { t.Fatalf("registry.gen.go missing %s:\n%s", want, 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") } 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) } if err := AddPlugin(ctx, dir, filepath.Join("plugins", "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) } if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", false); err != nil { t.Fatal(err) } if _, err := MakeMigration(ctx, dir, "golem15.demo", "AddAlbumIndex"); err != nil { t.Fatal(err) } if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err != nil { t.Fatal(err) } job, err := MakeJob(ctx, dir, "golem15.demo", "Reindex") if err != nil { t.Fatal(err) } if len(job.Files) != 1 { t.Fatalf("make:job files = %v", job.Files) } jobSrc, err := os.ReadFile(job.Files[0]) if err != nil { t.Fatal(err) } for _, want := range []string{ "func (ReindexArgs) Kind()", "func (reindexJob) Work(", "unexpected args type", } { 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) } if len(admin.Files) != 3 { t.Fatalf("make:admin-controller files = %v", admin.Files) } adminSrc, err := os.ReadFile(filepath.Join(pluginDir, "controllers", "albums.go")) if err != nil { t.Fatal(err) } for _, want := range []string{ `ID() string { return "golem15.demo.albums" }`, `ModelName() string { return "Albums" }`, `ConfigDir() string { return "controllers/albums" }`, } { if !bytes.Contains(adminSrc, []byte(want)) { t.Fatalf("albums.go missing %s:\n%s", want, adminSrc) } } for _, rel := range []string{ filepath.Join("controllers", "albums", "fields.yaml"), filepath.Join("controllers", "albums", "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) } } reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) if err != nil { t.Fatal(err) } for _, want := range []string{ "&models.Album{}", "updates.CreateAlbums()", "updates.AddAlbumIndex()", "console.PurgeCommand()", "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", "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 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", "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 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/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) } }