Test the release launcher behavior
This commit is contained in:
@@ -240,7 +240,7 @@ publication to the module-selected native CLI. Commit the stage.
|
||||
|
||||
## Stage 3: cover launcher behavior with fast offline tests
|
||||
|
||||
**Status:** pending. **Prerequisite:** Stage 2.
|
||||
**Status:** complete. **Prerequisite:** Stage 2.
|
||||
|
||||
**Files:** `scripts/release_test.go`, plus launcher corrections found by the
|
||||
tests and stage evidence in this plan. Use a tests-only external Go package such
|
||||
@@ -278,6 +278,13 @@ as `release_test`; add no production Go file or exported helper API.
|
||||
the default offline suite with the Stage 1 environment. None of these tests
|
||||
compiles a real publisher or contacts a service.
|
||||
|
||||
Observed 2026-10-08: `gofmt -d scripts/release_test.go` reported no changes.
|
||||
`go test -mod=readonly -count=1 ./scripts` passed. The offline default
|
||||
`go test -mod=readonly -count=1 ./...` passed for the library, internal adapter,
|
||||
examples, and scripts package. The tests use only temporary fixtures and a
|
||||
controlled Go executable; they do not build the real publisher or contact a
|
||||
service.
|
||||
|
||||
**Completion:** the small launcher has regression coverage for its own parsing,
|
||||
path, build, and delegation responsibilities. Commit the stage.
|
||||
|
||||
|
||||
405
scripts/release_test.go
Normal file
405
scripts/release_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package release_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
releasePackage = "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit"
|
||||
releaseVersion = "v1.2.3-rc.1+local"
|
||||
)
|
||||
|
||||
func TestReleaseLauncherArgumentHandling(t *testing.T) {
|
||||
shell := requirePOSIXShell(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "no arguments"},
|
||||
{name: "multiple arguments", args: []string{"v1.2.3", "extra"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fixture := newFixture(t)
|
||||
result := runLauncher(t, shell, fixture, test.args, nil)
|
||||
|
||||
if result.status != 2 {
|
||||
t.Fatalf("status = %d, want 2; stderr: %q", result.status, result.stderr)
|
||||
}
|
||||
if result.stdout != "" {
|
||||
t.Fatalf("stdout = %q, want empty", result.stdout)
|
||||
}
|
||||
if result.stderr != "usage: ./scripts/release VERSION\n" {
|
||||
t.Fatalf("stderr = %q, want usage", result.stderr)
|
||||
}
|
||||
if _, err := os.Stat(fixture.goLog); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Go invocation log exists or could not be checked: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseLauncherDelegatesPublication(t *testing.T) {
|
||||
shell := requirePOSIXShell(t)
|
||||
for _, status := range []int{0, 1, 2, 3} {
|
||||
t.Run(fmt.Sprintf("exit status %d", status), func(t *testing.T) {
|
||||
fixture := newFixture(t)
|
||||
result := runLauncher(t, shell, fixture, []string{releaseVersion}, map[string]string{
|
||||
"CDPATH": "/tmp",
|
||||
"GOOS": "windows",
|
||||
"GOARCH": "arm64",
|
||||
"PUBLISH_EXIT": strconv.Itoa(status),
|
||||
"PUBLISH_STDOUT": "publisher stdout",
|
||||
"PUBLISH_STDERR": "publisher stderr",
|
||||
})
|
||||
|
||||
if result.status != status {
|
||||
t.Fatalf("status = %d, want %d; stderr: %q", result.status, status, result.stderr)
|
||||
}
|
||||
if result.stdout != "publisher stdout\n" {
|
||||
t.Fatalf("stdout = %q, want publisher output", result.stdout)
|
||||
}
|
||||
if result.stderr != "publisher stderr\n" {
|
||||
t.Fatalf("stderr = %q, want publisher error output", result.stderr)
|
||||
}
|
||||
|
||||
wantArgs := []string{
|
||||
"publish",
|
||||
"--repo", fixture.repoRoot,
|
||||
"--config", filepath.Join(fixture.repoRoot, "releasekit.json"),
|
||||
releaseVersion,
|
||||
}
|
||||
if got := readLines(t, fixture.publishLog); !equalStrings(got, wantArgs) {
|
||||
t.Fatalf("publisher args = %#v, want %#v", got, wantArgs)
|
||||
}
|
||||
assertNativeBootstrap(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseLauncherStopsAfterBootstrapFailure(t *testing.T) {
|
||||
shell := requirePOSIXShell(t)
|
||||
fixture := newFixture(t)
|
||||
result := runLauncher(t, shell, fixture, []string{releaseVersion}, map[string]string{
|
||||
"FAKE_GO_BUILD_EXIT": "23",
|
||||
})
|
||||
|
||||
if result.status != 23 {
|
||||
t.Fatalf("status = %d, want build status 23; stderr: %q", result.status, result.stderr)
|
||||
}
|
||||
if _, err := os.Stat(fixture.publishLog); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("publisher was invoked or its log could not be checked: %v", err)
|
||||
}
|
||||
if !hasGoBuildCall(t, fixture) {
|
||||
t.Fatal("Go build invocation was not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
type launcherResult struct {
|
||||
status int
|
||||
stdout string
|
||||
stderr string
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
repoRoot string
|
||||
launcher string
|
||||
outside string
|
||||
fakeBin string
|
||||
goLog string
|
||||
publishLog string
|
||||
publisher string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("POSIX shell is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
_, sourceFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("could not locate test source")
|
||||
}
|
||||
repo := filepath.Dir(filepath.Dir(sourceFile))
|
||||
root := t.TempDir()
|
||||
repoRoot := filepath.Join(root, "CommandKit fixture with spaces")
|
||||
outside := filepath.Join(root, "outside working directory")
|
||||
fakeBin := filepath.Join(root, "controlled tools")
|
||||
for _, dir := range []string{filepath.Join(repoRoot, "scripts"), outside, fakeBin} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
copyFile(t, filepath.Join(repo, "scripts", "release"), filepath.Join(repoRoot, "scripts", "release"), 0o755)
|
||||
copyFile(t, filepath.Join(repo, "releasekit.json"), filepath.Join(repoRoot, "releasekit.json"), 0o644)
|
||||
|
||||
goLog := filepath.Join(root, "go calls.log")
|
||||
publishLog := filepath.Join(root, "publisher arguments.log")
|
||||
publisher := filepath.Join(root, "fake publisher")
|
||||
writeFile(t, publisher, fakePublisher, 0o644)
|
||||
writeFile(t, filepath.Join(fakeBin, "go"), fakeGo, 0o755)
|
||||
|
||||
return fixture{
|
||||
repoRoot: repoRoot,
|
||||
launcher: filepath.Join(repoRoot, "scripts", "release"),
|
||||
outside: outside,
|
||||
fakeBin: fakeBin,
|
||||
goLog: goLog,
|
||||
publishLog: publishLog,
|
||||
publisher: publisher,
|
||||
}
|
||||
}
|
||||
|
||||
func runLauncher(t *testing.T, shell string, fixture fixture, args []string, overrides map[string]string) launcherResult {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, shell, append([]string{fixture.launcher}, args...)...)
|
||||
cmd.Dir = fixture.outside
|
||||
cmd.Env = fixtureEnvironment(fixture, overrides)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
result := launcherResult{stdout: stdout.String(), stderr: stderr.String()}
|
||||
if err == nil {
|
||||
return result
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.status = exitErr.ExitCode()
|
||||
return result
|
||||
}
|
||||
t.Fatalf("run launcher: %v", err)
|
||||
return launcherResult{}
|
||||
}
|
||||
|
||||
func fixtureEnvironment(fixture fixture, overrides map[string]string) []string {
|
||||
values := map[string]string{
|
||||
"PATH": fixture.fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"GO_LOG": fixture.goLog,
|
||||
"PUBLISH_ARGS_LOG": fixture.publishLog,
|
||||
"FAKE_GOHOSTOS": "linux",
|
||||
"FAKE_GOHOSTARCH": "amd64",
|
||||
"FAKE_PUBLISHER_SOURCE": fixture.publisher,
|
||||
"FAKE_GO_BUILD_EXIT": "0",
|
||||
"PUBLISH_EXIT": "0",
|
||||
"PUBLISH_STDOUT": "publisher stdout",
|
||||
"PUBLISH_STDERR": "publisher stderr",
|
||||
}
|
||||
for key, value := range overrides {
|
||||
values[key] = value
|
||||
}
|
||||
var env []string
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if _, overridden := values[key]; !overridden {
|
||||
env = append(env, entry)
|
||||
}
|
||||
}
|
||||
for key, value := range values {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func assertNativeBootstrap(t *testing.T, fixture fixture) {
|
||||
t.Helper()
|
||||
calls := readGoCalls(t, fixture.goLog)
|
||||
var foundOS, foundArch, foundBuild bool
|
||||
for _, call := range calls {
|
||||
if call.cwd != fixture.repoRoot {
|
||||
t.Fatalf("Go ran in %q, want repository root %q", call.cwd, fixture.repoRoot)
|
||||
}
|
||||
if call.args[0] == "env" {
|
||||
if call.gowork != "off" || call.toolchain != "local" {
|
||||
t.Fatalf("Go env settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain)
|
||||
}
|
||||
switch strings.Join(call.args, " ") {
|
||||
case "env GOHOSTOS":
|
||||
foundOS = true
|
||||
case "env GOHOSTARCH":
|
||||
foundArch = true
|
||||
}
|
||||
}
|
||||
if call.args[0] == "build" {
|
||||
foundBuild = true
|
||||
want := []string{
|
||||
"build", "-mod=readonly", "-o",
|
||||
filepath.Join(fixture.repoRoot, ".releasekit", "bin", "releasekit"),
|
||||
releasePackage,
|
||||
}
|
||||
if !equalStrings(call.args, want) {
|
||||
t.Fatalf("build args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
if call.goos != "linux" || call.goarch != "amd64" {
|
||||
t.Fatalf("build target = %s/%s, want linux/amd64", call.goos, call.goarch)
|
||||
}
|
||||
if call.gowork != "off" || call.toolchain != "local" {
|
||||
t.Fatalf("build settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOS || !foundArch || !foundBuild {
|
||||
t.Fatalf("host queries and build not all observed: os=%t arch=%t build=%t", foundOS, foundArch, foundBuild)
|
||||
}
|
||||
}
|
||||
|
||||
type goCall struct {
|
||||
cwd string
|
||||
goos string
|
||||
goarch string
|
||||
gowork string
|
||||
toolchain string
|
||||
args []string
|
||||
}
|
||||
|
||||
func readGoCalls(t *testing.T, path string) []goCall {
|
||||
t.Helper()
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var calls []goCall
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Split(scanner.Text(), "\t")
|
||||
if len(fields) < 6 {
|
||||
t.Fatalf("malformed Go call record: %q", scanner.Text())
|
||||
}
|
||||
argc, err := strconv.Atoi(fields[5])
|
||||
if err != nil || len(fields) != 6+argc {
|
||||
t.Fatalf("malformed Go arguments in record: %q", scanner.Text())
|
||||
}
|
||||
calls = append(calls, goCall{
|
||||
cwd: fields[0],
|
||||
goos: fields[1],
|
||||
goarch: fields[2],
|
||||
gowork: fields[3],
|
||||
toolchain: fields[4],
|
||||
args: fields[6:],
|
||||
})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func hasGoBuildCall(t *testing.T, fixture fixture) bool {
|
||||
t.Helper()
|
||||
for _, call := range readGoCalls(t, fixture.goLog) {
|
||||
if len(call.args) > 0 && call.args[0] == "build" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readLines(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, source, destination string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, destination, string(data), mode)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, data string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func requirePOSIXShell(t *testing.T) string {
|
||||
t.Helper()
|
||||
shell, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("POSIX shell is unavailable")
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
func equalStrings(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fakeGo = `#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s' "$PWD" "${GOOS-}" "${GOARCH-}" "${GOWORK-}" "${GOTOOLCHAIN-}" "$#" >> "$GO_LOG"
|
||||
for arg do printf '\t%s' "$arg" >> "$GO_LOG"; done
|
||||
printf '\n' >> "$GO_LOG"
|
||||
|
||||
if [ "$1" = env ]; then
|
||||
case "$2" in
|
||||
GOHOSTOS) printf '%s\n' "$FAKE_GOHOSTOS" ;;
|
||||
GOHOSTARCH) printf '%s\n' "$FAKE_GOHOSTARCH" ;;
|
||||
*) exit 90 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = build ]; then
|
||||
if [ "$FAKE_GO_BUILD_EXIT" -ne 0 ]; then exit "$FAKE_GO_BUILD_EXIT"; fi
|
||||
output=
|
||||
expect_output=0
|
||||
for arg do
|
||||
if [ "$expect_output" -eq 1 ]; then
|
||||
output=$arg
|
||||
expect_output=0
|
||||
continue
|
||||
fi
|
||||
if [ "$arg" = -o ]; then expect_output=1; fi
|
||||
done
|
||||
[ -n "$output" ] || exit 91
|
||||
cat "$FAKE_PUBLISHER_SOURCE" > "$output"
|
||||
chmod +x "$output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 92
|
||||
`
|
||||
|
||||
const fakePublisher = `#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\n' "$@" > "$PUBLISH_ARGS_LOG"
|
||||
printf '%s\n' "$PUBLISH_STDOUT"
|
||||
printf '%s\n' "$PUBLISH_STDERR" >&2
|
||||
exit "$PUBLISH_EXIT"
|
||||
`
|
||||
Reference in New Issue
Block a user