package app import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" ) func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) { tests := []struct { name string args []string want string }{ { name: "separated", args: []string{"--content-addressed-root", "/var/cache/blobs"}, want: "/var/cache/blobs", }, { name: "inline", args: []string{"--content-addressed-root=/var/cache/blobs"}, want: "/var/cache/blobs", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts, err := parseBuildHAKArgs(tt.args) if err != nil { t.Fatalf("parse build-haks args: %v", err) } if opts.contentAddressedRoot != tt.want { t.Fatalf("content-addressed root = %q, want %q", opts.contentAddressedRoot, tt.want) } }) } } func TestParseBuildHAKArgsHelpListsContentAddressedRoot(t *testing.T) { _, err := parseBuildHAKArgs([]string{"--help"}) if err == nil { t.Fatal("expected help usage error") } for _, flag := range []string{ "--hak", "--archive", "--source-manifest", "--content-addressed-root", "--plan-only", "--quiet", "--verbose", "--debug", } { if !strings.Contains(err.Error(), flag) { t.Errorf("help usage missing documented flag %q: %v", flag, err) } } } func TestParseBuildHAKArgsRejectsRemovedMusicFlags(t *testing.T) { for _, args := range [][]string{ {"--skip-music"}, {"--music-dataset", "westgate"}, {"--music-dataset=westgate"}, } { if _, err := parseBuildHAKArgs(args); err == nil { t.Errorf("expected removed music arguments %v to fail", args) } } } func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "build")) mkdirAll(t, filepath.Join(root, ".cache", "2da")) mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: test_module topdata: source: topdata build: .cache package_hak: sow_top.hak package_tlk: sow_tlk.tlk `) writeFile(t, filepath.Join(root, ".cache", "2da", "repadjust.2da"), "2DA V2.0\n\n Label\n0 TEST_LABEL\n") writeFile(t, filepath.Join(root, "build", "sow_tlk.tlk"), "compiled tlk") writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data") writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{ "output": "repadjust.2da", "columns": ["Label"], "rows": [{"id": 0, "Label": "TEST_LABEL"}] }`+"\n") sourceTime := time.Now().Add(-2 * time.Hour) outputTime := time.Now().Add(-1 * time.Hour) setTreeTime(t, filepath.Join(root, "topdata"), sourceTime) setFileTime(t, filepath.Join(root, ".cache", "2da", "repadjust.2da"), outputTime) setFileTime(t, filepath.Join(root, "build", "sow_tlk.tlk"), outputTime) ctx := context{ stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, cwd: root, args: []string{"build-top-package"}, } if err := runBuildTopPackage(ctx); err != nil { t.Fatalf("runBuildTopPackage failed: %v", err) } if _, err := os.Stat(filepath.Join(root, "build", "sow_top.hak")); err != nil { t.Fatalf("expected packaged hak output: %v", err) } } func TestRunBuildHAKsPacksAuthoredBMU(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets", "envi", "music", "westgate")) mkdirAll(t, filepath.Join(root, "build")) mkdirAll(t, filepath.Join(root, "src")) writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src assets: assets build: build haks: - name: envi priority: 1 max_bytes: 1048576 split: false include: - envi/** `) writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "westgate_theme.bmu"), "authored-bmu") var stdout bytes.Buffer ctx := context{ stdout: &stdout, stderr: &bytes.Buffer{}, cwd: root, args: []string{"build-haks"}, } if err := runBuildHAKs(ctx); err != nil { t.Fatalf("runBuildHAKs failed: %v", err) } manifest, err := os.ReadFile(filepath.Join(root, "build", "haks.json")) if err != nil { t.Fatalf("read HAK manifest: %v", err) } if !strings.Contains(string(manifest), "envi/music/westgate/westgate_theme.bmu") { t.Fatalf("authored BMU missing from HAK manifest:\n%s", manifest) } } func setTreeTime(t *testing.T, root string, modTime time.Time) { t.Helper() err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } return os.Chtimes(path, modTime, modTime) }) if err != nil { t.Fatalf("set tree time under %s: %v", root, err) } } func TestTopdataConsoleSuppressesProgressInNormalMode(t *testing.T) { var stdout bytes.Buffer console := &topdataConsole{ stdout: &stdout, projectRoot: "/workspace/project", projectName: "Test Module", commandName: "build-topdata", commandLabel: "Build Topdata", level: logLevelNormal, } console.progress("Packaging compiled topdata resources into sow_top.hak...") if stdout.String() != "" { t.Fatalf("expected no normal-mode progress output, got %q", stdout.String()) } } func TestSpinnerEnabledForHonorsPlainTTYMode(t *testing.T) { t.Setenv("SOW_TOOLS_TTY_MODE", "plain") if spinnerEnabledFor(&bytes.Buffer{}, logLevelNormal) { t.Fatal("expected plain tty mode to disable spinner output") } } func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) { var stdout bytes.Buffer console := &topdataConsole{ stdout: &stdout, projectRoot: "/workspace/project", projectName: "Test Module", commandName: "deploy-wiki", commandLabel: "Deploy Wiki", level: logLevelDebug, } console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0") console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json") output := stdout.String() if !strings.Contains(output, "NodeBB wiki plan") { t.Fatalf("expected debug progress line, got %q", output) } if !strings.Contains(output, "build/wiki/deploy-manifest.json") || strings.Contains(output, "/workspace/project/") { t.Fatalf("expected relative deploy manifest path, got %q", output) } } func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) { _, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"}) if err == nil || !strings.Contains(err.Error(), "--stale-policy ") { t.Fatalf("expected deploy-wiki help to list purge stale policy, got %v", err) } } func TestParseDeployWikiResetManagedNamespacesFlag(t *testing.T) { opts, err := parseDeployWikiArgs("deploy-wiki", []string{"--reset-managed-namespaces"}) if err != nil { t.Fatalf("parse deploy wiki reset flag: %v", err) } if !opts.ResetManagedNamespaces { t.Fatalf("expected --reset-managed-namespaces to enable namespace reset") } } func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) { var stdout bytes.Buffer console := &projectConsole{ stdout: &stdout, projectRoot: "/workspace/project", projectName: "Test Module", commandName: "build-module", commandLabel: "Build Module", level: logLevelNormal, } console.progress("Writing module archive...") if stdout.String() != "" { t.Fatalf("expected no normal-mode progress output, got %q", stdout.String()) } } func TestProjectConsoleEmitsRelativePaths(t *testing.T) { var stdout bytes.Buffer console := &projectConsole{ stdout: &stdout, projectRoot: "/workspace/project", projectName: "Test Module", commandName: "compare", commandLabel: "Compare", level: logLevelNormal, } console.emitCompareResult(pipeline.CompareResult{ ModulePath: "/workspace/project/build/test.mod", HAKPaths: []string{"/workspace/project/build/core.hak"}, Checked: 42, }) output := stdout.String() if !strings.Contains(output, "build/test.mod") || strings.Contains(output, "/workspace/project/") { t.Fatalf("expected relative module path, got %q", output) } } func TestRunConfigEffectiveJSONShowsDefaultsAndProvenance(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src `) var stdout bytes.Buffer ctx := context{ stdout: &stdout, stderr: &bytes.Buffer{}, cwd: root, args: []string{"config", "effective", "--json"}, } if err := runConfig(ctx); err != nil { t.Fatalf("runConfig failed: %v", err) } var effective map[string]any if err := json.Unmarshal(stdout.Bytes(), &effective); err != nil { t.Fatalf("effective config is not JSON: %v\n%s", err, stdout.String()) } provenance, ok := effective["provenance"].(map[string]any) if !ok || len(provenance) == 0 { t.Fatalf("effective config missing provenance: %#v", effective["provenance"]) } if _, ok := provenance["paths.build"]; !ok { t.Fatalf("effective config missing provenance for omitted paths.build: %#v", provenance) } } func TestRunConfigExplainReportsYAMLSource(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: build: output `) var stdout bytes.Buffer ctx := context{ stdout: &stdout, stderr: &bytes.Buffer{}, cwd: root, args: []string{"config", "explain", "paths.build"}, } if err := runConfig(ctx); err != nil { t.Fatalf("runConfig failed: %v", err) } output := stdout.String() if !strings.Contains(output, "output") { t.Fatalf("expected configured build value, got %q", output) } if !strings.Contains(strings.ToLower(output), "yaml") { t.Fatalf("expected YAML source, got %q", output) } } func TestRunConfigValidateDoesNotScanSourceInventory(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "build")) writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: missing-src build: build `) ctx := context{ stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, cwd: root, args: []string{"config", "validate"}, } if err := runConfig(ctx); err != nil { t.Fatalf("runConfig failed: %v", err) } } func TestRunConfigSourcesListsActiveOverrides(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` module: name: Test Module resref: testmod paths: source: src `) t.Setenv("SOW_BUILD_HAKS_KEEP_EXISTING", "1") var stdout bytes.Buffer ctx := context{ stdout: &stdout, stderr: &bytes.Buffer{}, cwd: root, args: []string{"config", "sources"}, } if err := runConfig(ctx); err != nil { t.Fatalf("runConfig failed: %v", err) } output := stdout.String() if !strings.Contains(output, "build.keep_existing_haks") { t.Fatalf("expected keep existing override, got %q", output) } } func TestInlineFlagParsersRejectEmptyValues(t *testing.T) { if _, err := parseBuildHAKArgs([]string{"--hak="}); err == nil { t.Fatalf("expected empty --hak inline value error, got %v", err) } if _, err := parseDeployWikiArgs("deploy-wiki", []string{"--endpoint="}); err == nil { t.Fatalf("expected empty --endpoint inline value error, got %v", err) } if _, err := parseBuildChangelogArgs("build-changelog", []string{"--output="}); err == nil { t.Fatalf("expected empty --output inline value error, got %v", err) } } func TestParseBuildChangelogArgsAcceptsInlineValues(t *testing.T) { opts, err := parseBuildChangelogArgs("build-changelog", []string{ "--config=scripts/changelog.json", "--output=CHANGELOG.md", "--current-tag=v2", "--previous-tag=v1", "--api-base-url=https://gitea.example/api/v1", "--token=secret", }) if err != nil { t.Fatalf("parseBuildChangelogArgs failed: %v", err) } if opts.configPath != "scripts/changelog.json" || opts.outputPath != "CHANGELOG.md" || opts.currentTag != "v2" || opts.previousTag != "v1" || opts.apiBaseURL != "https://gitea.example/api/v1" || opts.token != "secret" { t.Fatalf("unexpected changelog opts: %#v", opts) } } func mkdirAll(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { t.Fatalf("mkdir %s: %v", path, err) } } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func setFileTime(t *testing.T, path string, modTime time.Time) { t.Helper() if err := os.Chtimes(path, modTime, modTime); err != nil { t.Fatalf("set file time %s: %v", path, err) } }