diff --git a/modules/bonfire/docs_test.go b/modules/bonfire/docs_test.go new file mode 100644 index 00000000..d3aeedde --- /dev/null +++ b/modules/bonfire/docs_test.go @@ -0,0 +1,50 @@ +package bonfire + +import ( + "reflect" + "testing" +) + +// TestCheckDocs: every missing item is reported in a fixed order, blank +// text counts as missing, results are sorted by command name and a fully +// documented command reports nothing. +func TestCheckDocs(t *testing.T) { + complete := Command{ + Name: "acme:sync", Description: "Sync", Long: "Syncs.", + Args: []Arg{{Name: "target", Description: "Where to"}}, + Flags: []Flag{{Name: "dry-run", Description: "Only print", Bare: true}}, + Examples: []Example{{Command: "acme:sync prod"}}, + } + if got := CheckDocs([]Command{complete}); len(got) != 0 { + t.Fatalf("complete command reported: %v", got) + } + if got := CheckDocs(nil); len(got) != 0 { + t.Fatalf("no commands reported: %v", got) + } + + blank := complete + blank.Name, blank.Description, blank.Long = "acme:blank", " ", "\n\t" + noExamples := complete + noExamples.Name, noExamples.Examples = "acme:noex", nil + undocumented := Command{ + Name: "acme:bare", + Args: []Arg{{Name: "a"}, {Name: "b", Description: "B"}}, + Flags: []Flag{{Name: "x"}, {Name: "y", Description: "Y"}, {Name: "z", Description: " "}}, + } + got := CheckDocs([]Command{noExamples, complete, undocumented, blank}) + want := []DocProblem{ + {Command: "acme:bare", Missing: []string{"description", "long help", "examples", "argument a description", "option --x description", "option --z description"}}, + {Command: "acme:blank", Missing: []string{"description", "long help"}}, + {Command: "acme:noex", Missing: []string{"examples"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("CheckDocs =\n %v\nwant\n %v", got, want) + } + if s := got[1].String(); s != "acme:blank: missing description, long help" { + t.Errorf("String() = %q", s) + } + // The builtins list and help are documented. + if got := CheckDocs(builtinCommands()); len(got) != 0 { + t.Errorf("builtin commands reported: %v", got) + } +} diff --git a/modules/bonfire/golden_test.go b/modules/bonfire/golden_test.go new file mode 100644 index 00000000..d62cbcc7 --- /dev/null +++ b/modules/bonfire/golden_test.go @@ -0,0 +1,268 @@ +package bonfire + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +// goldenVersion is the fixed version line of every golden. +var goldenVersion = Version{App: "v1.4.0", Framework: "v0.3.2"} + +// plainEnv clears the environment variables that force or detect color, so +// goldens render the same on every machine: no escape codes unless --ansi. +func plainEnv(t *testing.T) { + t.Helper() + for _, k := range []string{"FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "COLORTERM", "NO_COLOR"} { + t.Setenv(k, "") + _ = os.Unsetenv(k) + } + t.Setenv("TERM", "dumb") +} + +// goldenTokenCommand is the admin:token:create command of the D-14 +// preview: value, bare and repeatable flags with defaults, long help and +// two examples. The values are fake. +func goldenTokenCommand() Command { + return Command{ + Name: "admin:token:create", + Description: "Create a personal API token for a backend admin", + Long: "Creates a token for the admin with the given login and prints it once.\nStore it in the client right away; it cannot be shown again.", + Args: []Arg{{Name: "login", Description: "The admin's login", Required: true}}, + Flags: []Flag{ + {Name: "scopes", Description: "Comma-separated scopes", Default: "*"}, + {Name: "expires", Description: "Days until expiry", Default: "90"}, + {Name: "json", Description: "Print the token as JSON", Bare: true}, + {Name: "tag", Shorthand: "t", Description: "A tag for the token", Repeatable: true}, + }, + Examples: []Example{ + {Command: "admin:token:create jane --scopes=journal.*", Description: "Token for the MCP server, journal only"}, + {Command: "admin:token:create jane --json", Description: "Script-friendly output"}, + }, + Run: func(context.Context, Input, Output) error { return nil }, + } +} + +// goldenCommands is the command set of the list goldens (D-13 preview plus +// a hidden and a passthrough command). +func goldenCommands() []Command { + ok := func(context.Context, Input, Output) error { return nil } + return []Command{ + {Name: "migrate", Description: "Run the database migrations", Run: ok}, + {Name: "serve", Description: "Serve the application", Run: ok}, + {Name: "admin:create", Description: "Create a backend administrator", Run: ok}, + goldenTokenCommand(), + {Name: "make:command", Description: "Create a new console command", Run: ok}, + {Name: "make:model", Description: "Create a new model and migration", Run: ok}, + {Name: "acme:secret", Description: "Rotate the internal signing secret", Hidden: true, Run: ok}, + goldenPassthroughCommand(), + } +} + +// goldenPassthroughCommand forwards everything after its name. +func goldenPassthroughCommand() Command { + return Command{ + Name: "tool:exec", + Description: "Run the bundled tool with its own arguments", + Long: "Everything after the command name reaches the tool unchanged, global flags included.", + Passthrough: true, + Args: []Arg{{Name: "args", Description: "The tool's arguments"}}, + Examples: []Example{{Command: "tool:exec --version", Description: "The tool's own version"}}, + Run: func(context.Context, Input, Output) error { return nil }, + } +} + +// runGolden executes args on a fresh root over commands and returns stdout +// and stderr. +func runGolden(t *testing.T, commands []Command, args ...string) (string, string, int) { + t.Helper() + var out, errW bytes.Buffer + root, err := NewRootIO("acme", commands, strings.NewReader(""), &out, &errW, WithVersion(goldenVersion)) + if err != nil { + t.Fatal(err) + } + code := Execute(context.Background(), root, args) + return out.String(), errW.String(), code +} + +// assertGolden compares got with testdata/.golden, rewriting the file +// first when -update is set. +func assertGolden(t *testing.T, name string, got []byte) { + t.Helper() + path := filepath.Join("testdata", name+".golden") + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v (run go test -run %s -update to create it)", name, err, t.Name()) + } + if !bytes.Equal(got, want) { + t.Errorf("%s differs from %s\n%s", name, path, firstDiff(want, got)) + } +} + +// firstDiff prints the first differing line of want and got with a little +// context, in a unified-diff-like form. +func firstDiff(want, got []byte) string { + wl := strings.Split(string(want), "\n") + gl := strings.Split(string(got), "\n") + for i := 0; i < max(len(wl), len(gl)); i++ { + var w, g string + if i < len(wl) { + w = wl[i] + } + if i < len(gl) { + g = gl[i] + } + if w == g { + continue + } + var b strings.Builder + for j := max(0, i-2); j < i; j++ { + fmt.Fprintf(&b, " %q\n", wl[j]) + } + fmt.Fprintf(&b, "- %q\n+ %q\n", w, g) + return b.String() + } + return "(same lines, different line endings)" +} + +// TestListGolden: the bare binary prints the D-13 list. +func TestListGolden(t *testing.T) { + plainEnv(t) + out, errOut, code := runGolden(t, goldenCommands()) + if code != ExitOK || errOut != "" { + t.Fatalf("exit %d, stderr %q", code, errOut) + } + assertGolden(t, "list", []byte(out)) + for _, args := range [][]string{{"list"}, {"--help"}, {"-h"}, {"help"}, {"-v"}, {"--no-ansi"}} { + again, _, code := runGolden(t, goldenCommands(), args...) + if code != ExitOK || again != out { + t.Errorf("%v: exit %d, output differs from the bare list:\n%s", args, code, firstDiff([]byte(out), []byte(again))) + } + } +} + +// TestListAllGolden: list --all adds the hidden command. +func TestListAllGolden(t *testing.T) { + plainEnv(t) + out, _, code := runGolden(t, goldenCommands(), "list", "--all") + if code != ExitOK { + t.Fatalf("exit %d", code) + } + assertGolden(t, "list_all", []byte(out)) +} + +// TestListJSONGolden: list --format=json prints the catalog. +func TestListJSONGolden(t *testing.T) { + plainEnv(t) + out, _, code := runGolden(t, goldenCommands(), "list", "--format=json") + if code != ExitOK { + t.Fatalf("exit %d", code) + } + assertGolden(t, "list.json", []byte(out)) +} + +// TestListStyledGolden: --ansi styles the list in the palette (ANSI256), +// so a palette change shows up in review. +func TestListStyledGolden(t *testing.T) { + plainEnv(t) + out, _, code := runGolden(t, goldenCommands(), "--ansi") + if code != ExitOK { + t.Fatalf("exit %d", code) + } + if !strings.Contains(out, "\x1b[") { + t.Fatalf("--ansi list has no escape codes:\n%q", out) + } + assertGolden(t, "list_styled", []byte(out)) +} + +// TestHelpGolden: admin:token:create --help in the D-14 layout. +func TestHelpGolden(t *testing.T) { + plainEnv(t) + out, _, code := runGolden(t, goldenCommands(), "admin:token:create", "--help") + if code != ExitOK { + t.Fatalf("exit %d", code) + } + assertGolden(t, "help_token_create", []byte(out)) +} + +// TestHelpPassthroughGolden: the help of a passthrough command, reached +// through help because --help after its name is forwarded. +func TestHelpPassthroughGolden(t *testing.T) { + plainEnv(t) + out, _, code := runGolden(t, goldenCommands(), "help", "tool:exec") + if code != ExitOK { + t.Fatalf("exit %d", code) + } + assertGolden(t, "help_passthrough", []byte(out)) +} + +// TestHelpEqualsHelpFlag: help , --help and +// -h print the same bytes, with or without global flags. +func TestHelpEqualsHelpFlag(t *testing.T) { + plainEnv(t) + for _, name := range []string{"admin:token:create", "migrate", "list", "help"} { + want, _, code := runGolden(t, goldenCommands(), "help", name) + if code != ExitOK || !strings.Contains(want, "Usage:\n") { + t.Fatalf("help %s: exit %d\n%s", name, code, want) + } + for _, args := range [][]string{{name, "--help"}, {name, "-h"}, {"-v", "help", name}, {"--no-ansi", name, "--help"}} { + got, _, code := runGolden(t, goldenCommands(), args...) + if code != ExitOK || got != want { + t.Errorf("%v: exit %d, differs from help %s:\n%s", args, code, name, firstDiff([]byte(want), []byte(got))) + } + } + } +} + +// TestErrorGolden: the error block of D-18 for a hint with {bin}, an +// unknown command with a suggestion and the -v chain, on stderr. +func TestErrorGolden(t *testing.T) { + plainEnv(t) + hinted := Command{Name: "make:plugin", Description: "Create a plugin", Run: func(context.Context, Input, Output) error { + return WithHint(errors.New("No application found in /home/jane/acme/plugins."), + "summer make:command needs an application root (summer.yaml).", + "cd ..; {bin} make:command acme.blog:publish") + }} + chained := Command{Name: "db:check", Description: "Check the database", Run: func(context.Context, Input, Output) error { + return fmt.Errorf("db:check: %w", fmt.Errorf("connect to 127.0.0.1:5432: %w", errors.New("connection refused"))) + }} + commands := append(goldenCommands(), hinted, chained) + + cases := []struct { + golden string + args []string + code int + }{ + {"error_hint", []string{"make:plugin"}, ExitFailure}, + {"error_unknown_command", []string{"migrat"}, ExitUsage}, + {"error_chain_verbose", []string{"-v", "db:check"}, ExitFailure}, + } + for _, tc := range cases { + t.Run(tc.golden, func(t *testing.T) { + out, errOut, code := runGolden(t, commands, tc.args...) + if code != tc.code { + t.Fatalf("exit %d, want %d", code, tc.code) + } + if out != "" { + t.Errorf("the error block reached stdout:\n%s", out) + } + assertGolden(t, tc.golden, []byte(errOut)) + }) + } +} diff --git a/modules/bonfire/testdata/error_chain_verbose.golden b/modules/bonfire/testdata/error_chain_verbose.golden new file mode 100644 index 00000000..fa584d4b --- /dev/null +++ b/modules/bonfire/testdata/error_chain_verbose.golden @@ -0,0 +1,7 @@ + + ERROR db:check: connect to 127.0.0.1:5432: connection refused + + Caused by: + - connect to 127.0.0.1:5432: connection refused + - connection refused + diff --git a/modules/bonfire/testdata/error_hint.golden b/modules/bonfire/testdata/error_hint.golden new file mode 100644 index 00000000..31616676 --- /dev/null +++ b/modules/bonfire/testdata/error_hint.golden @@ -0,0 +1,10 @@ + + ERROR No application found in /home/jane/acme/plugins. + + summer make:command needs an application root (summer.yaml). + + Try: + cd ..; bin/acme make:command acme.blog:publish + + Run with -v for details. + diff --git a/modules/bonfire/testdata/error_unknown_command.golden b/modules/bonfire/testdata/error_unknown_command.golden new file mode 100644 index 00000000..662127fe --- /dev/null +++ b/modules/bonfire/testdata/error_unknown_command.golden @@ -0,0 +1,8 @@ + + ERROR Command "migrat" is not defined. + + Try: + bin/acme migrate + + Run with -v for details. + diff --git a/modules/bonfire/testdata/help_passthrough.golden b/modules/bonfire/testdata/help_passthrough.golden new file mode 100644 index 00000000..2306a560 --- /dev/null +++ b/modules/bonfire/testdata/help_passthrough.golden @@ -0,0 +1,23 @@ +Description: + Run the bundled tool with its own arguments + +Usage: + tool:exec [args] [options] [--] + +Arguments: + args The tool's arguments + +Global options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity + --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question + --env The environment the command runs under + +Help: + Everything after the command name reaches the tool unchanged, global flags included. + +Examples: + # The tool's own version + bin/acme tool:exec --version diff --git a/modules/bonfire/testdata/help_token_create.golden b/modules/bonfire/testdata/help_token_create.golden new file mode 100644 index 00000000..602f8fa0 --- /dev/null +++ b/modules/bonfire/testdata/help_token_create.golden @@ -0,0 +1,33 @@ +Description: + Create a personal API token for a backend admin + +Usage: + admin:token:create [options] + +Arguments: + login The admin's login + +Options: + --scopes=SCOPES Comma-separated scopes [default: "*"] + --expires=EXPIRES Days until expiry [default: 90] + --json Print the token as JSON + -t, --tag=TAG A tag for the token (multiple values allowed) + +Global options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity + --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question + --env The environment the command runs under + +Help: + Creates a token for the admin with the given login and prints it once. + Store it in the client right away; it cannot be shown again. + +Examples: + # Token for the MCP server, journal only + bin/acme admin:token:create jane --scopes=journal.* + + # Script-friendly output + bin/acme admin:token:create jane --json diff --git a/modules/bonfire/testdata/list.golden b/modules/bonfire/testdata/list.golden new file mode 100644 index 00000000..00289292 --- /dev/null +++ b/modules/bonfire/testdata/list.golden @@ -0,0 +1,26 @@ +SummerCMS acme v1.4.0 (framework v0.3.2) + +Usage: + bin/acme [options] [arguments] + +Options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity + --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question + --env The environment the command runs under + +Available commands: + help Display help for a command + list List commands + migrate Run the database migrations + serve Serve the application + admin + admin:create Create a backend administrator + admin:token:create Create a personal API token for a backend admin + make + make:command Create a new console command + make:model Create a new model and migration + tool + tool:exec Run the bundled tool with its own arguments diff --git a/modules/bonfire/testdata/list.json.golden b/modules/bonfire/testdata/list.json.golden new file mode 100644 index 00000000..7c42a286 --- /dev/null +++ b/modules/bonfire/testdata/list.json.golden @@ -0,0 +1,273 @@ +{ + "application": { + "name": "acme", + "version": "v1.4.0", + "framework": "v0.3.2" + }, + "global_options": [ + { + "name": "help", + "shorthand": "h", + "description": "Display help for the given command", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "quiet", + "shorthand": "q", + "description": "Do not output any message", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "verbose", + "shorthand": "v", + "description": "Increase verbosity (-v, -vv or -vvv)", + "default": "", + "bare": true, + "repeatable": true + }, + { + "name": "ansi", + "shorthand": "", + "description": "Force ANSI output", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "no-ansi", + "shorthand": "", + "description": "Disable ANSI output", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "no-interaction", + "shorthand": "n", + "description": "Do not ask any interactive question", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "env", + "shorthand": "", + "description": "The environment the command runs under", + "default": "", + "bare": false, + "repeatable": false + } + ], + "commands": [ + { + "name": "acme:secret", + "description": "Rotate the internal signing secret", + "long": "", + "usage": "acme:secret [options]", + "hidden": true, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "admin:create", + "description": "Create a backend administrator", + "long": "", + "usage": "admin:create [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "admin:token:create", + "description": "Create a personal API token for a backend admin", + "long": "Creates a token for the admin with the given login and prints it once.\nStore it in the client right away; it cannot be shown again.", + "usage": "admin:token:create \u003clogin\u003e [options]", + "hidden": false, + "passthrough": false, + "arguments": [ + { + "name": "login", + "description": "The admin's login", + "required": true + } + ], + "options": [ + { + "name": "scopes", + "shorthand": "", + "description": "Comma-separated scopes", + "default": "*", + "bare": false, + "repeatable": false + }, + { + "name": "expires", + "shorthand": "", + "description": "Days until expiry", + "default": "90", + "bare": false, + "repeatable": false + }, + { + "name": "json", + "shorthand": "", + "description": "Print the token as JSON", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "tag", + "shorthand": "t", + "description": "A tag for the token", + "default": "", + "bare": false, + "repeatable": true + } + ], + "examples": [ + { + "command": "admin:token:create jane --scopes=journal.*", + "description": "Token for the MCP server, journal only" + }, + { + "command": "admin:token:create jane --json", + "description": "Script-friendly output" + } + ] + }, + { + "name": "help", + "description": "Display help for a command", + "long": "Prints the description, usage, arguments, options, long help and examples of a command. Without a command name it prints the command list.", + "usage": "help [command_name] [options]", + "hidden": false, + "passthrough": false, + "arguments": [ + { + "name": "command_name", + "description": "The command name", + "required": false + } + ], + "options": [], + "examples": [ + { + "command": "help migrate", + "description": "Help for the migrate command" + } + ] + }, + { + "name": "list", + "description": "List commands", + "long": "Prints the commands grouped by namespace. --all includes hidden commands; --format=json prints every command with its arguments, options and examples.", + "usage": "list [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [ + { + "name": "all", + "shorthand": "", + "description": "Show hidden commands too", + "default": "", + "bare": true, + "repeatable": false + }, + { + "name": "format", + "shorthand": "", + "description": "The output format (txt or json)", + "default": "txt", + "bare": false, + "repeatable": false + } + ], + "examples": [ + { + "command": "list --all", + "description": "Every command, hidden ones included" + }, + { + "command": "list --format=json", + "description": "The catalog for scripts and agents" + } + ] + }, + { + "name": "make:command", + "description": "Create a new console command", + "long": "", + "usage": "make:command [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "make:model", + "description": "Create a new model and migration", + "long": "", + "usage": "make:model [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "migrate", + "description": "Run the database migrations", + "long": "", + "usage": "migrate [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "serve", + "description": "Serve the application", + "long": "", + "usage": "serve [options]", + "hidden": false, + "passthrough": false, + "arguments": [], + "options": [], + "examples": [] + }, + { + "name": "tool:exec", + "description": "Run the bundled tool with its own arguments", + "long": "Everything after the command name reaches the tool unchanged, global flags included.", + "usage": "tool:exec [args] [options] [--] \u003cargs...\u003e", + "hidden": false, + "passthrough": true, + "arguments": [ + { + "name": "args", + "description": "The tool's arguments", + "required": false + } + ], + "options": [], + "examples": [ + { + "command": "tool:exec --version", + "description": "The tool's own version" + } + ] + } + ] +} diff --git a/modules/bonfire/testdata/list_all.golden b/modules/bonfire/testdata/list_all.golden new file mode 100644 index 00000000..d1f6519a --- /dev/null +++ b/modules/bonfire/testdata/list_all.golden @@ -0,0 +1,28 @@ +SummerCMS acme v1.4.0 (framework v0.3.2) + +Usage: + bin/acme [options] [arguments] + +Options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity + --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question + --env The environment the command runs under + +Available commands: + help Display help for a command + list List commands + migrate Run the database migrations + serve Serve the application + acme + acme:secret Rotate the internal signing secret + admin + admin:create Create a backend administrator + admin:token:create Create a personal API token for a backend admin + make + make:command Create a new console command + make:model Create a new model and migration + tool + tool:exec Run the bundled tool with its own arguments diff --git a/modules/bonfire/testdata/list_styled.golden b/modules/bonfire/testdata/list_styled.golden new file mode 100644 index 00000000..6105967e --- /dev/null +++ b/modules/bonfire/testdata/list_styled.golden @@ -0,0 +1,26 @@ +SummerCMS acme v1.4.0 (framework v0.3.2) + +Usage: + bin/acme [options] [arguments] + +Options: + -h, --help Display help for the given command + -q, --quiet Do not output any message + -v|vv|vvv Increase verbosity +  --ansi|no-ansi Force (or disable) ANSI output + -n, --no-interaction Do not ask any interactive question +  --env The environment the command runs under + +Available commands: + help Display help for a command + list List commands + migrate Run the database migrations + serve Serve the application + admin + admin:create Create a backend administrator + admin:token:create Create a personal API token for a backend admin + make + make:command Create a new console command + make:model Create a new model and migration + tool + tool:exec Run the bundled tool with its own arguments diff --git a/modules/bonfire/version_test.go b/modules/bonfire/version_test.go new file mode 100644 index 00000000..9e759935 --- /dev/null +++ b/modules/bonfire/version_test.go @@ -0,0 +1,75 @@ +package bonfire + +import ( + "runtime/debug" + "testing" +) + +// TestVersionHeader: the list header and BuildVersion for a tagged +// framework dependency, a replaced (local) one, a go.work build (devel) +// and the summer tool's own build, read through an injected build info. +func TestVersionHeader(t *testing.T) { + headers := []struct { + v Version + name string + want string + }{ + {Version{App: "v1.4.0", Framework: "v0.3.2"}, "acme", "SummerCMS acme v1.4.0 (framework v0.3.2)"}, + {Version{App: "devel", Framework: "v0.4.0", Local: true}, "acme", "SummerCMS acme devel (framework v0.4.0, local)"}, + {Version{App: "v1.4.1-0.20261021120000-abcdef123456+dirty", Framework: "(devel)"}, "acme", "SummerCMS acme v1.4.1-0.20261021120000-abcdef123456+dirty (framework devel)"}, + // Equal versions collapse to one, as for the summer tool's own build. + {Version{App: "(devel)", Framework: "(devel)"}, "acme", "SummerCMS acme devel"}, + {Version{App: "v0.4.0", Framework: "v0.4.0"}, "summer", "SummerCMS summer v0.4.0"}, + {Version{App: "v0.4.0", Framework: "v0.4.0", Local: true}, "acme", "SummerCMS acme v0.4.0 (framework v0.4.0, local)"}, + {Version{}, "acme", "SummerCMS acme devel"}, + } + for _, tc := range headers { + if got := tc.v.Header(tc.name); got != tc.want { + t.Errorf("%+v.Header(%q) = %q, want %q", tc.v, tc.name, got, tc.want) + } + } + + dep := func(version string, replaced bool) *debug.Module { + m := &debug.Module{Path: frameworkModule, Version: version} + if replaced { + m.Replace = &debug.Module{Path: "../summercms.go", Version: "(devel)"} + } + return m + } + builds := []struct { + name string + info *debug.BuildInfo + ok bool + want Version + }{ + {"tagged", &debug.BuildInfo{Main: debug.Module{Path: "example.com/acme", Version: "v1.4.0"}, + Deps: []*debug.Module{{Path: "example.com/other", Version: "v9.9.9"}, dep("v0.3.2", false)}}, + true, Version{App: "v1.4.0", Framework: "v0.3.2"}}, + {"replaced", &debug.BuildInfo{Main: debug.Module{Path: "example.com/acme", Version: "(devel)"}, + Deps: []*debug.Module{dep("v0.4.0", true)}}, + true, Version{App: "devel", Framework: "v0.4.0", Local: true}}, + {"go.work", &debug.BuildInfo{Main: debug.Module{Path: "example.com/acme", Version: "v1.4.1-0.20261021120000-abcdef123456+dirty"}, + Deps: []*debug.Module{dep("(devel)", false)}}, + true, Version{App: "v1.4.1-0.20261021120000-abcdef123456+dirty", Framework: "devel"}}, + {"no framework dep", &debug.BuildInfo{Main: debug.Module{Path: "example.com/acme", Version: "v1.0.0"}}, + true, Version{App: "v1.0.0", Framework: "devel"}}, + {"summer", &debug.BuildInfo{Main: debug.Module{Path: frameworkModule, Version: "v0.4.0"}}, + true, Version{App: "v0.4.0", Framework: "v0.4.0"}}, + {"no build info", nil, false, Version{App: "devel", Framework: "devel"}}, + } + for _, tc := range builds { + t.Run(tc.name, func(t *testing.T) { + prev := readBuildInfo + t.Cleanup(func() { readBuildInfo = prev }) + readBuildInfo = func() (*debug.BuildInfo, bool) { return tc.info, tc.ok } + if got := BuildVersion(); got != tc.want { + t.Errorf("BuildVersion() = %+v, want %+v", got, tc.want) + } + }) + } + + // The test binary is built from the framework module itself. + if got := BuildVersion(); got.App != got.Framework || got.Local { + t.Errorf("the framework's own build = %+v, want App == Framework", got) + } +}