build-binaries / build-binaries (push) Successful in 2m27s
Closes #99. Closes #100. ## #99 — purge used the core topic API `deploy-wiki --stale-policy purge` deleted pages with `DELETE /api/v3/topics/{tid}`. On a NodeBB running `nodebb-plugin-westgate-wiki` that is refused for topics in wiki categories — 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: 1. `PUT /api/v3/plugins/westgate-wiki/page/tombstone` 2. `DELETE /api/v3/plugins/westgate-wiki/page/hard-purge` in that order, because a page must be tombstoned before it can be purged. A page that is already gone answers 404 on the tombstone and is treated as a completed purge, as before. **Archive was audited and needs no change.** It rewrites the page through `updatePost`, which is an ordinary post edit the plugin allows; only delete, restore, and purge are reserved to the page actions. **The wiki home topic.** A namespace reset enumerates every topic in the category, including the home page, which the plugin excludes from tombstone, restore, and purge alike. Those are now skipped instead of aborting the reset. NodeBB answers 403 for that and for a token without purge privileges alike, and the response body cannot tell the two apart — what can is scope. A category where nothing at all could be deleted is a privilege problem, so the run still fails there rather than writing a manifest that claims a fresh start over pages that are all still present. **The fakes.** Every fake NodeBB in `wiki_deploy_test.go` now goes through one constructor that refuses native topic mutation exactly the way the plugin does. The old fakes answered the core API, which is how a purge path that has never worked in production stayed green in CI. ## #100 — `stale: 0` above `purged: 1213` The reset purge never went through stale computation, so the preview reported zero deletions on a run that would delete every topic in the managed categories. Reset deletions are now counted in `stale`, which is the number callers word their destructive-policy warning around, and the summary gains a line naming the reset and how many of its targets the manifest has no record of writing: ``` stale: 1213 purged: 1213 namespace reset: 1213 (unrecognized: 13) unrecognized pages were not written by this deployer; recreating them is not possible ``` The unrecognized subset is the number worth surfacing, since those are the deletions a re-seed cannot undo. The `--reset-managed-namespaces` help text now says plainly that the flag deletes every page in the managed categories, not only the ones this deployer wrote. `DeployResult` is exported so the console reads named fields instead of eleven positional ints. ## Verification `go vet ./...` and `go test ./...` pass. New tests cover the plugin purge order, the already-missing page, the skipped undeletable topic, the per-category privilege failure, and the reset counts.Reviewed-on: #101 Co-authored-by: vickydotbat <vickydotbat@tutamail.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)
|
|
}
|
|
}
|