Files
sow-tools/internal/dispatch/dispatch_test.go
T
archvillainette 9357b30994
test / test (push) Successful in 1m31s
build-binaries / build-binaries (push) Successful in 2m27s
build-image / publish (push) Successful in 42s
crucible depot: Increment 1 core (status/push/verify/get/pull + local/cdn/bunny backends) (#30)
Implements Increment 1 of `docs/superpowers/specs/2026-07-04-crucible-depot-core-design.md`: a new stdlib-only `internal/depot` package wired into the dispatcher.

- `crucible depot status|push|verify|get|pull` with backends `local`/`cdn`/`bunny`; exit contract `0` clean / `1` drift / `2` unconfirmed-only / `64` usage / `70` internal.
- Presence is always probed against the real target (IPv4 1-byte range GET; HEAD is banned with a regression-tripwire test). `unconfirmed` is a distinct state, never collapsed into `missing`.
- No prompting anywhere: missing `BUNNY_STORAGE_*` env fails closed (read path included), enforced by a no-stdin test.
- Uploads: probe-then-PUT with `Checksum: <UPPER-sha>`; read/write key split (`BUNNY_STORAGE_READ_PASSWORD` falls back to `BUNNY_STORAGE_PASSWORD`).
- Field-driven fix included: per-probe transient retry (curl `--retry 2` equivalent) — without it a real 1490-blob CDN sweep reported 1222 false-unconfirmed; with it, 1490/1490 present in 74s, exit 0.
- Registry: depot `Wired: true`, joins the interactive menu; stale "(SeaweedFS)" wording removed.

Tests: unit + httptest fake-Bunny (probe sequence, Checksum header, key split, 428 throttling → exit 2) + local→bunny integration (drift → push → clean → idempotent no-second-PUT; incremental pull). `make check` green.

**Merge ordering:** this merges FIRST; the companion `sow-assets-manifest#crucible-depot-cutover` PR needs its flake input bumped to include this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #30
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-04 23:30:54 +00:00

328 lines
10 KiB
Go

package dispatch
import (
"bytes"
"strings"
"testing"
)
func TestVersion(t *testing.T) {
var out, errw bytes.Buffer
for _, arg := range []string{"version", "-V", "--version"} {
out.Reset()
errw.Reset()
if code := run([]string{arg}, &out, &errw); code != exitOK {
t.Fatalf("%s: exit=%d want %d", arg, code, exitOK)
}
if !strings.HasPrefix(out.String(), "crucible ") {
t.Fatalf("%s: version line %q missing prefix", arg, out.String())
}
}
}
func TestHelpAndNoArgs(t *testing.T) {
var out, errw bytes.Buffer
if code := run([]string{"help"}, &out, &errw); code != exitOK {
t.Fatalf("help exit=%d want %d", code, exitOK)
}
for _, builder := range Registry {
if !strings.Contains(out.String(), builder.Name) {
t.Errorf("help output missing builder %q:\n%s", builder.Name, out.String())
}
}
// No args is a usage error (exit 64) but still prints help.
out.Reset()
if code := run(nil, &out, &errw); code != exitUsage {
t.Fatalf("no-args exit=%d want %d", code, exitUsage)
}
if out.Len() == 0 {
t.Fatal("no-args usage error should include help")
}
}
func TestListCoversRegistry(t *testing.T) {
var out bytes.Buffer
list(&out)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != len(Registry) {
t.Fatalf("list returned %d rows for %d builders:\n%s", len(lines), len(Registry), out.String())
}
builders := make(map[string]Builder, len(Registry))
for _, builder := range Registry {
builders[builder.Name] = builder
}
seen := make(map[string]bool, len(lines))
for _, line := range lines {
fields := strings.Split(strings.TrimSpace(line), "\t")
if len(fields) != 3 {
t.Fatalf("list row must be name<TAB>bin<TAB>summary, got %q", line)
}
builder, ok := builders[fields[0]]
if !ok {
t.Errorf("list returned unregistered builder %q", fields[0])
continue
}
if fields[1] != builder.Bin {
t.Errorf("builder %q listed binary %q, want %q", builder.Name, fields[1], builder.Bin)
}
if strings.TrimSpace(fields[2]) == "" {
t.Errorf("builder %q listed an empty summary", builder.Name)
}
seen[fields[0]] = true
}
for _, builder := range Registry {
if !seen[builder.Name] {
t.Errorf("list missing builder %q", builder.Name)
}
}
}
func TestUnknownBuilderFailsUsage(t *testing.T) {
var out, errw bytes.Buffer
if code := run([]string{"frobnicate"}, &out, &errw); code != exitUsage {
t.Fatalf("unknown builder exit=%d want %d", code, exitUsage)
}
if errw.Len() == 0 {
t.Fatal("unknown builder should explain the usage error")
}
}
func TestUnwiredBuilderFailsClosed(t *testing.T) {
for _, b := range Registry {
if b.Wired {
continue
}
var out, errw bytes.Buffer
// Via dispatcher.
if code := run([]string{b.Name}, &out, &errw); code != exitUnwired {
t.Errorf("crucible %s: exit=%d want %d (must fail closed)", b.Name, code, exitUnwired)
}
if errw.Len() == 0 {
t.Errorf("crucible %s: missing fail-closed explanation", b.Name)
}
// Via standalone shim path.
out.Reset()
errw.Reset()
if code := runBuilder(b.Name, nil, &out, &errw); code != exitUnwired {
t.Errorf("%s: exit=%d want %d (must fail closed)", b.Bin, code, exitUnwired)
}
}
}
func TestWiredBuilderRejectsBadInvocation(t *testing.T) {
for _, b := range Registry {
if !b.Wired {
continue
}
// No subcommand is a usage error (it must never silently delegate).
var out, errw bytes.Buffer
if code := runBuilder(b.Name, nil, &out, &errw); code != exitUsage {
t.Errorf("crucible %s (no subcommand): exit=%d want %d", b.Name, code, exitUsage)
}
// Unknown subcommand is a usage error, not a delegate.
out.Reset()
errw.Reset()
if code := runBuilder(b.Name, []string{"frobnicate"}, &out, &errw); code != exitUsage {
t.Errorf("crucible %s frobnicate: exit=%d want %d", b.Name, code, exitUsage)
}
if errw.Len() == 0 {
t.Errorf("crucible %s frobnicate: missing usage explanation", b.Name)
}
}
}
func TestBuilderHelpIsOK(t *testing.T) {
for _, b := range Registry {
var out, errw bytes.Buffer
if code := runBuilder(b.Name, []string{"--help"}, &out, &errw); code != exitOK {
t.Errorf("%s --help: exit=%d want %d", b.Name, code, exitOK)
}
if !strings.Contains(out.String(), b.Name) || !strings.Contains(out.String(), b.Bin) {
t.Errorf("%s --help: missing builder identity", b.Name)
}
for _, subcommand := range b.subcommands() {
if !strings.Contains(out.String(), subcommand) {
t.Errorf("%s --help: missing subcommand %q", b.Name, subcommand)
}
}
}
}
func TestCanonicalCommandSurface(t *testing.T) {
want := map[string][]string{
"depot": {"status", "push", "verify", "get", "pull"},
"hak": {"build", "manifest"},
"module": {"build", "extract", "validate", "compare", "manifest"},
"topdata": {"validate", "build", "package", "compare", "convert"},
"wiki": {"build", "deploy"},
}
for _, builder := range Registry {
got := builder.subcommands()
expected, ok := want[builder.Name]
if !ok {
t.Fatalf("unexpected builder %q", builder.Name)
}
if strings.Join(got, ",") != strings.Join(expected, ",") {
t.Errorf("%s commands = %v, want %v", builder.Name, got, expected)
}
}
}
func TestRegistryCommandNamesAndAliasesAreUnambiguous(t *testing.T) {
for _, builder := range Registry {
seen := map[string]bool{}
for _, command := range builder.Commands {
// depot parses its own subcommands and bypasses AppCommand routing
// entirely (see the depot special-case in runBuilder), so its
// Commands carry no AppCommand.
requireAppCommand := builder.Name != "depot"
if command.Name == "" || command.Summary == "" || command.Usage == "" || (requireAppCommand && command.AppCommand == "") {
t.Errorf("%s has incomplete command metadata: %#v", builder.Name, command)
}
if seen[command.Name] {
t.Errorf("%s repeats command name %q", builder.Name, command.Name)
}
seen[command.Name] = true
for _, alias := range command.Aliases {
if alias.Name == "" {
t.Errorf("%s %s has an empty alias", builder.Name, command.Name)
continue
}
if seen[alias.Name] {
t.Errorf("%s repeats command or alias %q", builder.Name, alias.Name)
}
seen[alias.Name] = true
}
}
}
}
func TestMusicCommandsAreNotAccepted(t *testing.T) {
for _, builderName := range []string{"hak", "module"} {
builder, ok := find(builderName)
if !ok {
t.Fatalf("missing builder %q", builderName)
}
if builder.accepts("music") {
t.Errorf("%s must not accept the removed music command", builderName)
}
}
}
func TestHiddenAliasesPreserveImplementationTargets(t *testing.T) {
tests := []struct {
builder string
alias string
target string
}{
{"hak", "build-haks", "build-haks"},
{"hak", "apply-hak-manifest", "apply-hak-manifest"},
{"module", "build-module", "build-module"},
{"module", "apply-hak-manifest", "apply-hak-manifest"},
{"topdata", "validate-topdata", "validate-topdata"},
{"topdata", "build-topdata", "build-topdata"},
{"topdata", "build-top-package", "build-top-package"},
{"topdata", "compare-topdata", "compare-topdata"},
{"topdata", "convert-topdata", "convert-topdata"},
{"wiki", "build-wiki", "build-wiki"},
{"wiki", "deploy-wiki", "deploy-wiki"},
}
for _, tt := range tests {
t.Run(tt.builder+"/"+tt.alias, func(t *testing.T) {
builder, ok := find(tt.builder)
if !ok {
t.Fatalf("missing builder %q", tt.builder)
}
command, target, ok := builder.command(tt.alias)
if !ok {
t.Fatalf("hidden alias %q is not accepted", tt.alias)
}
if target != tt.target {
t.Fatalf("alias %q target = %q, want %q", tt.alias, target, tt.target)
}
if command.Name == tt.alias {
t.Fatalf("alias %q must not be a visible command", tt.alias)
}
})
}
}
func TestBuilderHelpHidesCompatibilityAliases(t *testing.T) {
aliases := map[string][]string{
"hak": {"build-haks", "apply-hak-manifest"},
"module": {"build-module", "apply-hak-manifest"},
"topdata": {"validate-topdata", "build-topdata", "build-top-package", "compare-topdata", "convert-topdata"},
"wiki": {"build-wiki", "deploy-wiki"},
}
for builderName, hidden := range aliases {
var out, errw bytes.Buffer
if code := runBuilder(builderName, []string{"--help"}, &out, &errw); code != exitOK {
t.Fatalf("%s help exit = %d", builderName, code)
}
for _, alias := range hidden {
if strings.Contains(out.String(), alias) {
t.Errorf("%s help exposed hidden alias %q:\n%s", builderName, alias, out.String())
}
}
}
}
func TestCommandHelpUsesCanonicalGuidanceWithoutLoadingProject(t *testing.T) {
var out, errw bytes.Buffer
if code := runBuilder("topdata", []string{"build", "--help"}, &out, &errw); code != exitOK {
t.Fatalf("command help exit = %d, stderr:\n%s", code, errw.String())
}
for _, want := range []string{
"compile topdata",
"usage: crucible topdata build",
"--force",
"--skip-lfs",
} {
if !strings.Contains(out.String(), want) {
t.Errorf("command help missing %q:\n%s", want, out.String())
}
}
if errw.Len() != 0 {
t.Fatalf("command help must not load a project or emit errors:\n%s", errw.String())
}
}
func TestMenuItemsSkipsUnwiredIncludesWired(t *testing.T) {
items := menuItems()
if len(items) == 0 {
t.Fatal("expected menu items")
}
sawModuleBuild := false
sawDepotStatus := false
for _, it := range items {
if len(it.Args) == 2 && it.Args[0] == "module" && it.Args[1] == "build" {
sawModuleBuild = true
}
if len(it.Args) == 2 && it.Args[0] == "depot" && it.Args[1] == "status" {
sawDepotStatus = true
}
}
if !sawModuleBuild {
t.Error("expected 'module build' in the menu")
}
if !sawDepotStatus {
t.Error("expected wired builder 'depot status' in the menu")
}
}
// TestDepotRoutesToDepotRun asserts the depot special-case in runBuilder
// reaches depot.Run rather than the generic Commands/delegateLegacy path or
// the unwired fail-closed path. With no manifests dir present, depot.Run's
// status command fails resolving the backend/manifest (exit 70), but the
// stderr text must come from depot, never the dispatcher's unwiredMsg.
func TestDepotRoutesToDepotRun(t *testing.T) {
var out, errw bytes.Buffer
code := runBuilder("depot", []string{"status", "--target", "local"}, &out, &errw)
if errw.Len() == 0 {
t.Fatalf("depot status with no manifests dir: expected an error from depot.Run, got no stderr (exit=%d)", code)
}
if strings.Contains(errw.String(), "not wired yet") {
t.Fatalf("depot status: stderr shows the dispatcher's unwired message, want depot.Run's own error:\n%s", errw.String())
}
}