ci / ci (pull_request) Successful in 3m52s
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.
Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.
Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.
A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.
Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.
Closes #99
Closes #100
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
517 lines
14 KiB
Go
517 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
|
)
|
|
|
|
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(topdata.DeployResult{
|
|
LocalPages: 10,
|
|
Created: 1,
|
|
Updated: 2,
|
|
Skipped: 3,
|
|
Stale: 4,
|
|
Archived: 5,
|
|
Purged: 6,
|
|
Manifest: "/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 TestTopdataConsoleReportsManagedNamespaceReset(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
console := &topdataConsole{
|
|
stdout: &stdout,
|
|
projectRoot: "/workspace/project",
|
|
projectName: "Test Module",
|
|
commandName: "deploy-wiki",
|
|
commandLabel: "Deploy Wiki",
|
|
}
|
|
|
|
console.emitWikiDeployResult(topdata.DeployResult{
|
|
LocalPages: 1200,
|
|
Created: 1200,
|
|
Stale: 1213,
|
|
Purged: 1213,
|
|
ResetPurged: 1213,
|
|
ResetUnrecognized: 13,
|
|
ResetSkipped: 1,
|
|
Manifest: "/workspace/project/build/wiki/deploy-manifest.json",
|
|
})
|
|
|
|
// The counts are what an operator reads to decide whether a destructive run
|
|
// is safe, so each has to reach the output on a line that names what it
|
|
// counts; the wording around them is free to change.
|
|
output := stdout.String()
|
|
var staleLine, resetLine, skippedLine string
|
|
for _, line := range strings.Split(output, "\n") {
|
|
switch {
|
|
case strings.Contains(line, "stale"):
|
|
staleLine = line
|
|
case strings.Contains(line, "skipped"):
|
|
skippedLine = line
|
|
case strings.Contains(line, "reset"):
|
|
resetLine = line
|
|
}
|
|
}
|
|
if !strings.Contains(staleLine, "1213") {
|
|
t.Fatalf("expected reset deletions counted as stale, got %q", output)
|
|
}
|
|
if !strings.Contains(resetLine, "1213") || !strings.Contains(resetLine, "13") {
|
|
t.Fatalf("expected the reset line to carry both the total and the unrecognized count, got %q", output)
|
|
}
|
|
if !strings.Contains(skippedLine, "1") {
|
|
t.Fatalf("expected refused reset deletions to be reported, got %q", output)
|
|
}
|
|
}
|
|
|
|
func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
|
|
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
|
|
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
|
|
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)
|
|
}
|
|
}
|