feat(11.1-05): pin the walkthrough to the scaffolder and link it from the concept map

- TestScaffoldLayout runs make:plugin, make:model, make:migration,
  make:admin-controller and make:command for acme.blog in a copy of
  examples/hello and compares the file set with docs/examples/blog
- the page lists the exact make commands, the go.mod a scaffolded plugin
  gets, what the scaffolder leaves to the developer and a checklist
- scaffolding.md no longer claims same-second migrations get consecutive
  timestamps; only same-name ones do
- coming-from-wintercms.md and index.md link the walkthrough
This commit is contained in:
Jakub Zych
2026-09-30 23:41:00 +02:00
parent dd82b8a2ad
commit 63290c66d3
5 changed files with 217 additions and 3 deletions

View File

@@ -0,0 +1,143 @@
package blog_test
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/internal/build"
)
// TestScaffoldLayout pins the walkthrough to the scaffolder: running the
// make commands the porting page lists for acme.blog, in its order, in a
// copy of the examples/hello application, must produce exactly the files of
// this plugin (without its tests, and with migration timestamps normalised). The
// in-repository copy has no go.mod or go.sum because it lives in the
// framework module; those two are left out of the scaffolder's set.
func TestScaffoldLayout(t *testing.T) {
// The make commands run go mod tidy; keep it on the module cache.
t.Setenv("GOPROXY", "off")
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatal(err)
}
app := copyApp(t, filepath.Join(root, "examples", "hello"), root)
ctx := t.Context()
if _, err := build.MakePlugin(ctx, app, "acme.blog"); err != nil {
t.Fatalf("MakePlugin: %v", err)
}
if _, err := build.MakeModel(ctx, app, "acme.blog", "Post", false); err != nil {
t.Fatalf("MakeModel: %v", err)
}
if _, err := build.MakeMigration(ctx, app, "acme.blog", "AddPublishedAt"); err != nil {
t.Fatalf("MakeMigration: %v", err)
}
if _, err := build.MakeAdminController(ctx, app, "acme.blog", "Posts"); err != nil {
t.Fatalf("MakeAdminController: %v", err)
}
if _, err := build.MakeCommand(ctx, app, "acme.blog", "Publish"); err != nil {
t.Fatalf("MakeCommand: %v", err)
}
scaffolded := fileSet(t, filepath.Join(app, "plugins", "blog"), func(rel string) bool {
return rel != "go.mod" && rel != "go.sum"
})
walkthrough := fileSet(t, ".", func(rel string) bool {
return !strings.HasSuffix(rel, "_test.go")
})
var missing, extra []string
for _, f := range scaffolded {
if !slices.Contains(walkthrough, f) {
missing = append(missing, f)
}
}
for _, f := range walkthrough {
if !slices.Contains(scaffolded, f) {
extra = append(extra, f)
}
}
if len(missing) > 0 || len(extra) > 0 {
t.Errorf("docs/examples/blog differs from the scaffolder output\nscaffolded but missing here: %v\nhere but not scaffolded: %v", missing, extra)
}
}
var migrationStamp = regexp.MustCompile(`^updates/\d{14}_`)
// fileSet returns the sorted relative paths of the files under dir that keep
// accepts, with a leading 14-digit migration timestamp replaced by a token.
func fileSet(t *testing.T, dir string, keep func(rel string) bool) []string {
t.Helper()
var out []string
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if keep(rel) {
out = append(out, migrationStamp.ReplaceAllString(rel, "updates/TIMESTAMP_"))
}
return nil
})
if err != nil {
t.Fatal(err)
}
slices.Sort(out)
return out
}
// copyApp copies the application at src into a temporary directory, without
// its bin/ directory, and points every go.mod replace of the framework
// module at framework.
func copyApp(t *testing.T, src, framework string) string {
t.Helper()
const module = "git.golem15.com/golem15/summercms"
dst := t.TempDir()
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if d.IsDir() {
if rel == "bin" {
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
if d.Name() == "go.mod" {
lines := strings.Split(string(data), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "replace "))
if strings.HasPrefix(trimmed, module+" =>") {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
prefix := ""
if strings.HasPrefix(strings.TrimSpace(line), "replace ") {
prefix = "replace "
}
lines[i] = indent + prefix + module + " => " + framework
}
}
data = []byte(strings.Join(lines, "\n"))
}
return os.WriteFile(filepath.Join(dst, rel), data, 0o644)
})
if err != nil {
t.Fatal(err)
}
return dst
}