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 "}, {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}, } { 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(), "; profile=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()) } writeInspectionProfiles(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 TestConfigDiffReportsSemanticProfileChanges(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) writeInspectionProfiles(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 profiles:\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, "profiles", "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, "--profile", "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-profile 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) writeInspectionProfiles(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) writeInspectionProfiles(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 profiles 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, "--profile", "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 TestConfigDiffBootstrapsSecretsOnceForBothProfiles(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) writeInspectionProfiles(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, } { 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) if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatal(err) } writeInspectionProfiles(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\n", ) var show bytes.Buffer if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &show); err != nil { t.Fatalf("config show: %v", err) } var sources bytes.Buffer if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil { t.Fatalf("config sources: %v", err) } var catalog bytes.Buffer if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "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) } 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"} { 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") { t.Fatalf("diff omitted safe credential references: %q", diff.String()) } } 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 profile diff = %q, want model change", diff.String()) } if _, err := os.Stat(workspacePath); !os.IsNotExist(err) { t.Fatalf("inspection created example workspace: %v", err) } } func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) { t.Helper() profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles") mustWriteTestFile(t, filepath.Join(profilesDir, "production.yml"), production) mustWriteTestFile(t, filepath.Join(profilesDir, "testing.yml"), testing) } func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) enableCommandTestProfiles(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(), "profile=none") { t.Fatalf("default output = %q", defaultOut.String()) } var explicitOut bytes.Buffer if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "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) enableCommandTestProfiles(t, pipelinePath) args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "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", "profile: name=testing selection=explicit overlay=", "campaign: id=sample-campaign source=", "party: source=", "digest: ", "path\trole\tsource\n", "workspace.root\troot\t", "whisperx.language\tprofile\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 TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) { pipelinePath, campaignPath := writeInspectionFamilyConfig(t) profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") mustWriteTestFile(t, profilePath, `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, "--profile", "testing"}, &out); err != nil { t.Fatalf("ConfigSources() error = %v", err) } want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath if !strings.Contains(out.String(), want) { t.Fatalf("sources output missing profile-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") profilePath := filepath.Join(dir, "profiles", "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, profilePath, `scriptorium: artifacts: mixed: inputs: prior: source: narratio.artifact.second `) var out bytes.Buffer if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "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\tprofile\t" + profilePath, } { 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 TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) { dir := t.TempDir() rootPath := filepath.Join(dir, "pipeline.yml") importPath := filepath.Join(dir, "base.yml") profilePath := filepath.Join(dir, "profiles", "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, profilePath, "whisperx:\n language: fr\n") var out bytes.Buffer if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--profile", "testing"}, &out); err != nil { t.Fatalf("ConfigSources() error = %v", err) } output := out.String() for _, want := range []string{ "import: " + importPath, "profile: name=testing selection=explicit overlay=" + profilePath, "workspace.root\troot\t" + rootPath, "storage.backend\timport\t" + importPath, "whisperx.language\tprofile\t" + profilePath, "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 }