Files
narratio/internal/app/config_commands_test.go

816 lines
31 KiB
Go

package app
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestConfigCommandHelpAndDispatch(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
code int
}{
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|artifacts|diff>"},
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
{name: "artifacts help", args: []string{"config", "artifacts", "--help"}, want: "Usage: narratio config artifacts"},
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|artifacts|diff", code: 1},
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
{name: "retired selector", args: []string{"config", "validate", "--profile", "testing"}, want: "flag provided but not defined", code: 1},
} {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(test.args, &stdout, &stderr)
if code != test.code {
t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String())
}
combined := stdout.String() + stderr.String()
if !strings.Contains(combined, test.want) {
t.Fatalf("output = %q, want %q", combined, test.want)
}
})
}
}
func TestExecuteConfigUsesInvocationPipelineLoader(t *testing.T) {
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
for _, subcommand := range []string{"validate", "show", "sources", "artifacts"} {
t.Run(subcommand, func(t *testing.T) {
calls := 0
deps := commandDependencies{
loadPipelineConfig: func(path string, opts config.PipelineLoadOptions) (*config.PipelineConfig, error) {
calls++
return config.LoadPipelineWithOptions(path, opts)
},
}
var stdout, stderr bytes.Buffer
code := executeWithDependencies(
[]string{"config", subcommand, "--config", pipelinePath},
&stdout,
&stderr,
deps,
executePlan,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if calls != 1 {
t.Fatalf("pipeline loader calls = %d, want 1", calls)
}
})
}
}
func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) {
dir := t.TempDir()
workspaceRoot := filepath.Join(dir, "workspace-does-not-exist")
pipelinePath := filepath.Join(dir, "pipeline.yml")
secretsDir := filepath.Join(dir, "secrets")
const secret = "inspection-secret-must-not-escape"
mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n")
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ }))
defer server.Close()
mustWriteTestFile(t, pipelinePath, `workspace:
root: `+workspaceRoot+`
whisperx:
transcribe_url: `+server.URL+`
`)
t.Setenv("INSPECTION_SECRET", secret)
var validateOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
t.Fatalf("ConfigValidate() error = %v", err)
}
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; overlay=none; digest=") {
t.Fatalf("validate output = %q", validateOut.String())
}
var showOut bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil {
t.Fatalf("ConfigShow() error = %v", err)
}
show := showOut.String()
if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) {
t.Fatalf("show output = %q", show)
}
if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") {
t.Fatalf("show output leaked resolution-only fields: %q", show)
}
if strings.Contains(show, secret) {
t.Fatalf("show output leaked a raw secret: %q", show)
}
var sourcesOut bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath}, &sourcesOut); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
if strings.Contains(sourcesOut.String(), secret) {
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
}
writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
var diffOut bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
t.Fatalf("ConfigDiff() error = %v", err)
}
if strings.Contains(diffOut.String(), secret) {
t.Fatalf("diff output leaked a raw secret: %q", diffOut.String())
}
if calls != 0 {
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
}
if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) {
t.Fatalf("workspace root stat error = %v, want absent", err)
}
}
func TestConfigDiffReportsSemanticOverlayChanges(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionOverlays(t, pipelinePath, `workspace:
cleanup_after_publish: true
publish:
upload_run: true
whisperx:
language: en
audita:
modules: [glossary, grammar]
scriptorium:
artifacts:
production_only:
enabled: false
output_path: artifacts/production.md
`, `workspace:
cleanup_after_publish: false
whisperx:
language: fr
audita:
modules: []
scriptorium:
artifacts:
testing_only:
enabled: false
output_path: artifacts/testing.md
`)
args := []string{"production", "testing", "--config", pipelinePath}
var first, second bytes.Buffer
if err := ConfigDiff(context.Background(), args, &first); err != nil {
t.Fatalf("ConfigDiff() error = %v", err)
}
if err := ConfigDiff(context.Background(), args, &second); err != nil {
t.Fatalf("second ConfigDiff() error = %v", err)
}
if first.String() != second.String() {
t.Fatalf("diff output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
}
output := first.String()
for _, want := range []string{
"changed\taudita.modules\t[\"glossary\",\"grammar\"]\t[]\n",
"changed\tworkspace.cleanup_after_publish\ttrue\tfalse\n",
"changed\twhisperx.language\t\"en\"\t\"fr\"\n",
"removed\tscriptorium.artifacts.production_only.enabled\tfalse\n",
"added\tscriptorium.artifacts.testing_only.enabled\tfalse\n",
} {
if !strings.Contains(output, want) {
t.Fatalf("diff output missing %q:\n%s", want, output)
}
}
if strings.Index(output, "audita.modules") > strings.Index(output, "workspace.cleanup_after_publish") {
t.Fatalf("diff records are not sorted by path:\n%s", output)
}
var reversed bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"testing", "production", "--config", pipelinePath}, &reversed); err != nil {
t.Fatalf("reversed ConfigDiff() error = %v", err)
}
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
t.Fatalf("reversed diff did not independently resolve overlays:\n%s", reversed.String())
}
}
func TestConfigShowPreservesNullAndEmptyCollections(t *testing.T) {
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
mustWriteTestFile(t, pipelinePath, `workspace:
root: /tmp/narratio-work
whisperx:
transcribe_url: https://transcription.example.test/transcribe
audita:
modules: null
scriptorium:
artifacts: null
`)
mustWriteTestFile(t, filepath.Join(dir, "overlays", "empty.yml"), `audita:
modules: []
scriptorium:
artifacts: {}
`)
var base, empty bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil {
t.Fatal(err)
}
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "empty"}, &empty); err != nil {
t.Fatal(err)
}
for _, want := range []string{"modules: null\n", "artifacts: null\n"} {
if !strings.Contains(base.String(), want) {
t.Fatalf("base show output missing %q:\n%s", want, base.String())
}
}
for _, want := range []string{"modules: []\n", "artifacts: {}\n"} {
if !strings.Contains(empty.String(), want) {
t.Fatalf("empty-overlay show output missing %q:\n%s", want, empty.String())
}
}
var diff bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"default", "empty", "--config", pipelinePath}, &diff); err != nil {
t.Fatal(err)
}
for _, want := range []string{
"changed\taudita.modules\tnull\t[]\n",
"changed\tscriptorium.artifacts\tnull\t{}\n",
} {
if !strings.Contains(diff.String(), want) {
t.Fatalf("diff output missing %q:\n%s", want, diff.String())
}
}
}
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
writeInspectionOverlays(t, pipelinePath, `scriptorium:
artifact_families:
character_note:
enabled: false
member_vars:
character_name: character.name
publish:
enabled: true
required: false
`, `scriptorium:
artifact_families:
character_note:
enabled: true
prompt_id: dnd.character_note
member_vars:
character_name: character.class_summary
publish:
enabled: true
required: true
`)
var out bytes.Buffer
if err := ConfigDiff(context.Background(), []string{
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
}, &out); err != nil {
t.Fatalf("ConfigDiff() error = %v", err)
}
output := out.String()
for _, want := range []string{
"changed\tscriptorium.artifacts.character_note_arannis.enabled\tfalse\ttrue\n",
"changed\tscriptorium.artifacts.character_note_arannis.vars.character_name\t\"Arannis\"\t\"wizard\"\n",
"changed\tpublish.outputs\t",
} {
if !strings.Contains(output, want) {
t.Fatalf("family diff output missing %q:\n%s", want, output)
}
}
}
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
var equal bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
t.Fatalf("ConfigDiff() equal overlays error = %v", err)
}
if got := equal.String(); got != "no differences\n" {
t.Fatalf("equal diff output = %q", got)
}
for _, args := range [][]string{
{"production", "testing", "extra", "--config", pipelinePath},
{"production", "--config", pipelinePath},
{"production", "production", "--config", pipelinePath},
{"unknown", "testing", "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--overlay", "production"},
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
} {
if err := ConfigDiff(context.Background(), args, io.Discard); err == nil {
t.Fatalf("ConfigDiff(%q) succeeded, want error", args)
}
}
}
func TestConfigDiffBootstrapsSecretsOnceForBothOverlays(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
secretsDir := t.TempDir()
const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT"
restoreEnvAfterTest(t, secretName)
if err := os.Unsetenv(secretName); err != nil {
t.Fatal(err)
}
mustWriteSecretFile(t, filepath.Join(secretsDir, secretName), "diff-secret-must-not-appear\n")
bootstrapCalls := 0
var diagnostics bytes.Buffer
deps := commandDependencies{loadSecretsDir: func(got string) (*configkit.SecretsReport, error) {
bootstrapCalls++
if got != secretsDir {
t.Fatalf("secrets directory = %q, want %q", got, secretsDir)
}
return configkit.LoadSecretsDir(got)
}, diagnostics: &diagnostics}
if err := configDiffWithDependencies(context.Background(), []string{
"production", "testing", "--config", pipelinePath, "--secrets-dir", secretsDir,
}, io.Discard, deps); err != nil {
t.Fatalf("config diff: %v", err)
}
if bootstrapCalls != 1 {
t.Fatalf("bootstrap calls = %d, want 1", bootstrapCalls)
}
if got := diagnostics.String(); strings.Count(got, `name="`+secretName+`"`) != 1 {
t.Fatalf("bootstrap report count = %d, want 1; diagnostics = %q", strings.Count(got, `name="`+secretName+`"`), got)
}
if strings.Contains(diagnostics.String(), "diff-secret-must-not-appear") {
t.Fatalf("bootstrap diagnostics exposed secret value: %q", diagnostics.String())
}
}
func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T) {
const firstCanary = "inspection-credential-canary-one"
const secondCanary = "inspection-credential-canary-two"
for name, value := range map[string]string{
"NARRATIO_INSPECT_ACCESS": firstCanary,
"NARRATIO_INSPECT_SECRET": firstCanary,
"NARRATIO_INSPECT_LLM_ONE": firstCanary,
"NARRATIO_INSPECT_LLM_TWO": secondCanary,
"NARRATIO_INSPECT_NOTARIUS_ONE": firstCanary,
"NARRATIO_INSPECT_NOTARIUS_TWO": secondCanary,
} {
t.Setenv(name, value)
}
workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
pipelineData, err := os.ReadFile(pipelinePath)
if err != nil {
t.Fatal(err)
}
pipelineYAML := strings.Replace(string(pipelineData), " bucket: test-bucket\n", ` bucket: test-bucket
access_key_id: NARRATIO_INSPECT_ACCESS
secret_access_key: NARRATIO_INSPECT_SECRET
`, 1)
pipelineYAML += `notarius:
enabled: false
llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err)
}
writeInspectionOverlays(t, pipelinePath,
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n",
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n",
)
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
var validate bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil {
t.Fatalf("config validate: %v", err)
}
var show bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &show); err != nil {
t.Fatalf("config show: %v", err)
}
var sources bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &sources); err != nil {
t.Fatalf("config sources: %v", err)
}
var catalog bytes.Buffer
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &catalog); err != nil {
t.Fatalf("config artifacts: %v", err)
}
var diff bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil {
t.Fatalf("config diff: %v", err)
}
var removed bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "without-notarius-key"}, &removed); err != nil {
t.Fatalf("config show with removed Notarius credential: %v", err)
}
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) {
t.Fatalf("%s output exposed credential value: %q", label, output)
}
}
for _, reference := range []string{"NARRATIO_INSPECT_ACCESS", "NARRATIO_INSPECT_SECRET", "NARRATIO_INSPECT_LLM_ONE", "NARRATIO_INSPECT_NOTARIUS_ONE"} {
if !strings.Contains(show.String(), reference) {
t.Fatalf("show omitted safe credential reference %q: %q", reference, show.String())
}
}
if !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_TWO") ||
!strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") {
t.Fatalf("diff omitted safe credential references: %q", diff.String())
}
if !strings.Contains(sources.String(), "notarius.llm_api_key") {
t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String())
}
if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
t.Fatalf("overlay null did not remove Notarius credential: %q", removed.String())
}
}
func TestConfigValidateRejectsUnavailableNotariusCredential(t *testing.T) {
const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING"
restoreEnvAfterTest(t, envName)
path := filepath.Join(t.TempDir(), "pipeline.yml")
mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n")
err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "secret.unset") {
t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err)
}
}
func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testing.T) {
examplesDir := filepath.Join("..", "..", "examples")
pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml")
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
workspacePath := filepath.Join(examplesDir, "production-testing", "workspace")
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
t.Fatalf("example workspace stat = %v, want absent", err)
}
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources, ConfigArtifacts} {
if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err != nil {
t.Fatalf("inspection command %T error = %v", command, err)
}
}
var diff bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"default", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil {
t.Fatalf("ConfigDiff() error = %v", err)
}
if !strings.Contains(diff.String(), "audita.model") {
t.Fatalf("split bundle overlay diff = %q, want model change", diff.String())
}
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
t.Fatalf("inspection created example workspace: %v", err)
}
}
func writeInspectionOverlays(t *testing.T, pipelinePath, production, testing string) {
t.Helper()
overlaysDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
mustWriteTestFile(t, filepath.Join(overlaysDir, "production.yml"), production)
mustWriteTestFile(t, filepath.Join(overlaysDir, "testing.yml"), testing)
}
func TestConfigCommandsSelectOverlaysAndCampaigns(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestOverlays(t, pipelinePath)
var defaultOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
t.Fatalf("default ConfigValidate() error = %v", err)
}
if !strings.Contains(defaultOut.String(), "overlay=none") {
t.Fatalf("default output = %q", defaultOut.String())
}
var explicitOut bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &explicitOut); err != nil {
t.Fatalf("explicit ConfigShow() error = %v", err)
}
if !strings.Contains(explicitOut.String(), "language: fr") {
t.Fatalf("explicit show output = %q", explicitOut.String())
}
for _, args := range [][]string{
{"--config", pipelinePath, "--campaign", "sample-campaign"},
{"--config", pipelinePath, "--campaign-file", campaignPath},
} {
var out bytes.Buffer
if err := ConfigValidate(context.Background(), args, &out); err != nil {
t.Fatalf("ConfigValidate(%q) error = %v", args, err)
}
}
}
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigArtifacts} {
var out bytes.Buffer
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
t.Fatalf("family command without campaign error = %v", err)
}
}
var show bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil {
t.Fatalf("ConfigShow() error = %v", err)
}
if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") {
t.Fatalf("expanded show output = %q", show.String())
}
loadedPipeline, err := config.LoadPipeline(pipelinePath)
if err != nil {
t.Fatal(err)
}
loadedCampaign, err := config.LoadCampaign(campaignPath)
if err != nil {
t.Fatal(err)
}
loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign)
if err != nil {
t.Fatal(err)
}
var validateOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) {
t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline))
}
}
func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n")
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("canonical party error = %v", err)
}
dir := t.TempDir()
rootPath := filepath.Join(dir, "pipeline.yml")
mustWriteTestFile(t, rootPath, `configkit:
imports:
- path: conf.yml
workspace:
root: /one
whisperx:
transcribe_url: https://transcription.example.com/transcribe
`)
mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n")
err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "/workspace/root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") {
t.Fatalf("import diagnostic = %v", err)
}
}
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestOverlays(t, pipelinePath)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
var first, second bytes.Buffer
if err := ConfigSources(context.Background(), args, &first); err != nil {
t.Fatalf("first ConfigSources() error = %v", err)
}
if err := ConfigSources(context.Background(), args, &second); err != nil {
t.Fatalf("second ConfigSources() error = %v", err)
}
if first.String() != second.String() {
t.Fatalf("sources output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
}
output := first.String()
for _, want := range []string{
"root: ",
"imports: none",
"overlay: name=testing selection=explicit path=",
"campaign: id=sample-campaign source=",
"party: source=",
"digest: ",
"path\trole\tsource\n",
"workspace.root\troot\t",
"whisperx.language\toverlay\t",
"derived.players\tparty\t",
"trim.enabled\tdefault\tdefault",
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
output := out.String()
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
for _, want := range []string{
"party: source=" + partyPath,
"derived.players\tparty\t" + partyPath,
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,
"scriptorium.artifacts.character_note_arannis.depends_on\tfamily\t" + pipelinePath,
"scriptorium.artifacts.character_note_arannis.inputs.prior.source\tparty\t" + partyPath,
"publish.outputs[",
"\tfamily\t" + pipelinePath,
"\tparty\t" + partyPath,
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func TestConfigSourcesPreservesOverlayOwnershipThroughFamilyExpansion(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families:
character_note:
enabled: true
prompt_id: dnd.character_note
`)
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + overlayPath
if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing overlay-generated ownership %q:\n%s", want, out.String())
}
}
func TestConfigSourcesAttributesInferredArtifactDependencies(t *testing.T) {
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
inputPath := filepath.Join(dir, "input-only.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `configkit:
imports:
- path: input-only.yml
workspace:
root: /srv/narratio
whisperx:
transcribe_url: https://transcription.example.test/transcribe
scriptorium:
artifacts:
first:
enabled: false
output_path: artifacts/first.md
second:
enabled: false
output_path: artifacts/second.md
mixed:
enabled: false
output_path: artifacts/mixed.md
depends_on: [first]
`)
mustWriteTestFile(t, inputPath, `scriptorium:
artifacts:
input_only:
enabled: false
output_path: artifacts/input-only.md
inputs:
prior:
source: narratio.artifact.first
`)
mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts:
mixed:
inputs:
prior:
source: narratio.artifact.second
`)
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
t.Fatal(err)
}
for _, want := range []string{
"scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath,
"scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath,
"scriptorium.artifacts.mixed.depends_on\toverlay\t" + overlayPath,
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "scriptorium.artifacts.input_only.depends_on\tdefault\tdefault") {
t.Fatalf("inferred dependency retained default attribution:\n%s", out.String())
}
}
func TestConfigSourcesReportsRootImportOverlayAndDefaultOwnership(t *testing.T) {
dir := t.TempDir()
rootPath := filepath.Join(dir, "pipeline.yml")
importPath := filepath.Join(dir, "base.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, rootPath, `configkit:
imports:
- path: base.yml
workspace:
root: /srv/narratio
whisperx:
transcribe_url: https://transcription.example.com/transcribe
`)
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
mustWriteTestFile(t, overlayPath, "whisperx:\n language: fr\n")
var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err)
}
output := out.String()
for _, want := range []string{
"import: " + importPath,
"overlay: name=testing selection=explicit path=" + overlayPath,
"workspace.root\troot\t" + rootPath,
"storage.backend\timport\t" + importPath,
"whisperx.language\toverlay\t" + overlayPath,
"trim.enabled\tdefault\tdefault",
} {
if !strings.Contains(output, want) {
t.Fatalf("sources output missing %q:\n%s", want, output)
}
}
}
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
campaignRoot := filepath.Join(dir, "campaigns")
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(campaignDir, "campaign.yml")
mustWriteTestFile(t, pipelinePath, `workspace:
root: `+filepath.Join(dir, "workspace")+`
campaigns:
root: `+campaignRoot+`
whisperx:
transcribe_url: https://transcription.example.com/transcribe
scriptorium:
artifact_families:
character_meta:
enabled: false
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/meta.md
character_note:
enabled: false
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/note.md
member_dependencies: [character_meta]
inputs:
prior: {source: narratio.member_artifact.character_meta, required: true}
member_vars:
character_name: character.name
publish:
enabled: true
required: true
publish:
enabled: true
upload_run: false
`)
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
party_file: ./party.yml
`)
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1
characters:
arannis:
player: {name: Eric}
character: {name: Arannis, classes: [{name: wizard}]}
`)
return pipelinePath, campaignPath
}