package cli import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "reflect" "sort" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/generic" "gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim" "gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder" "gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop" jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json" ) func TestRunNoArgsWritesUsageToStdout(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run(nil, &stdout, &stderr) if code != 0 { t.Fatalf("Run() code = %d, want 0", code) } if stdout.String() != usage { t.Fatalf("stdout = %q, want %q", stdout.String(), usage) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunHelpArgsWriteUsageToStdout(t *testing.T) { tests := []struct { name string args []string }{ {name: "help", args: []string{"help"}}, {name: "long help flag", args: []string{"--help"}}, {name: "short help flag", args: []string{"-h"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run(tt.args, &stdout, &stderr) if code != 0 { t.Fatalf("Run() code = %d, want 0", code) } if stdout.String() != usage { t.Fatalf("stdout = %q, want %q", stdout.String(), usage) } if !strings.Contains(stdout.String(), "config validate") || !strings.Contains(stdout.String(), "pipelines list") { t.Fatalf("usage does not mention new commands: %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } }) } } func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run([]string{"extract"}, &stdout, &stderr) if code != 2 { t.Fatalf("Run() code = %d, want 2", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } gotStderr := stderr.String() if !strings.Contains(gotStderr, "notarius: unknown command \"extract\"") { t.Fatalf("stderr = %q, want unknown command error", gotStderr) } if !strings.Contains(gotStderr, usage) { t.Fatalf("stderr = %q, want usage", gotStderr) } } func TestRunConfigValidateSuccessWithFakeCatalog(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "is valid for pipeline") { t.Fatalf("stdout = %q, want validation success", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestProductionCatalogIncludesDefaultModules(t *testing.T) { catalog, err := productionCatalog() if err != nil { t.Fatalf("productionCatalog() error = %v, want nil", err) } tests := []struct { name string got func() (pipeline.ModuleSpec, bool) want pipeline.ModuleSpec }{ { name: "seriatim input", got: func() (pipeline.ModuleSpec, bool) { return catalog.Inputs.Spec(seriatim.Key) }, want: seriatim.ModuleSpec(), }, { name: "generic chunker", got: func() (pipeline.ModuleSpec, bool) { return catalog.Chunkers.Spec(generic.Key) }, want: generic.ModuleSpec(), }, { name: "dnd scenes chunker", got: func() (pipeline.ModuleSpec, bool) { return catalog.Chunkers.Spec(scenes.Key) }, want: scenes.ModuleSpec(), }, { name: "dnd spells extractor", got: func() (pipeline.ModuleSpec, bool) { return catalog.Extractors.Spec(spells.Key) }, want: spells.ModuleSpec(), }, { name: "appendorder merger", got: func() (pipeline.ModuleSpec, bool) { return catalog.Mergers.Spec(appendorder.Key) }, want: appendorder.ModuleSpec(), }, { name: "noop normalizer", got: func() (pipeline.ModuleSpec, bool) { return catalog.Normalizers.Spec(noop.Key) }, want: noop.ModuleSpec(), }, { name: "json output", got: func() (pipeline.ModuleSpec, bool) { return catalog.Outputs.Spec(jsonoutput.Key) }, want: jsonoutput.ModuleSpec(), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, ok := test.got() if !ok { t.Fatalf("module spec ok = false, want true") } if !reflect.DeepEqual(got, test.want) { t.Fatalf("module spec = %#v, want %#v", got, test.want) } }) } } func TestRunConfigValidateUsesProductionCatalogByDefault(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "is valid for pipeline") { t.Fatalf("stdout = %q, want validation success", stdout.String()) } } func TestRunConfigValidateAcceptsDNDScenesChunker(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAMLWithChunk("dnd-session", scenes.Key, "dnd/spells")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "is valid for pipeline") { t.Fatalf("stdout = %q, want validation success", stdout.String()) } } func TestRunConfigValidateUnknownProductionModuleIncludesContext(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "missing/extract")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } got := stderr.String() for _, want := range []string{"dnd-session", "extract", "missing/extract", "not registered"} { if !strings.Contains(got, want) { t.Fatalf("stderr = %q, want substring %q", got, want) } } } func TestRunConfigValidateReportsParseErrors(t *testing.T) { configPath := writeFile(t, "config.yml", "version: 1\n") var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "unsupported config version") { t.Fatalf("stderr = %q, want parse error", stderr.String()) } } func TestRunConfigValidatePipelineOnlySuccessAndInvalidLane(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) t.Run("success", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "notes"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } }) t.Run("invalid lane", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "missing"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "selected artifact lane") { t.Fatalf("stderr = %q, want invalid lane error", stderr.String()) } }) } func TestRunConfigValidateOnlyWithoutPipelineFails(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "events"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "--only requires --pipeline") { t.Fatalf("stderr = %q, want only/pipeline error", stderr.String()) } } func TestRunConfigValidateRejectsMalformedOnlyValues(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) tests := []string{",", "notes,", ",notes", "events, ,notes"} for _, only := range tests { t.Run(only, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", only}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "--only") { t.Fatalf("stderr = %q, want --only error", stderr.String()) } }) } } func TestRunPipelinesListSortedTextOutput(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ "zeta": {"events"}, "alpha": {"events"}, })) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "alpha\nzeta\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunPipelinesListStableJSONOutput(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ "b": {"events"}, "a": {"events"}, })) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "{\"pipelines\":[\"a\",\"b\"]}\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, Options{ LookupEnv: mapLookup(map[string]string{"NOTARIUS_CONFIG": configPath}), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "example\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunConfigValidateRejectsStaleLLMProfiles(t *testing.T) { configPath := writeTestConfig(t, `version: 2 llm_profiles: default: {} pipelines: example: input: fake/input artifacts: events: extract: fake/extract `) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "llm_profiles") { t.Fatalf("stderr = %q, want stale llm_profiles error", stderr.String()) } } func TestRunMissingConfigPathProducesActionableError(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", filepath.Join(t.TempDir(), "missing.yml")}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "config file") || !strings.Contains(stderr.String(), "not available") { t.Fatalf("stderr = %q, want actionable missing config error", stderr.String()) } } func TestRunRejectsAdHocStructuralFlags(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) flags := []string{"--extractor", "--chunker", "--input", "--merge", "--normalize"} for _, flagName := range flags { t.Run(flagName, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, flagName, "value"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "flag provided but not defined") { t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) } }) } } func TestRunInvalidFlagsExitTwo(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--bogus"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "flag provided but not defined") { t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) } } func TestProductionLLMClientFactoryBuildsScriptoriumRuntime(t *testing.T) { cfg := config.Default() client, profiles, err := productionLLMClientFactory(context.Background(), cfg, "mistral-small-3") if err != nil { t.Fatalf("productionLLMClientFactory() error = %v, want nil", err) } if client == nil { t.Fatal("productionLLMClientFactory() client = nil, want client") } if profiles != nil { t.Fatalf("productionLLMClientFactory() profiles = %#v, want runtime-reported profiles", profiles) } } func TestRunPipelineMissingPipelineID(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "pipeline ID") { t.Fatalf("stderr = %q, want missing pipeline ID error", stderr.String()) } } func TestRunPipelineMissingInputFlag(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "--input") { t.Fatalf("stderr = %q, want missing input error", stderr.String()) } } func TestRunPipelineRejectsUnknownFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--extractor", "dnd/spells"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "flag provided but not defined") { t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) } } func TestRunPipelineUnknownPipeline(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "missing", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "pipeline \"missing\" is not configured") { t.Fatalf("stderr = %q, want unknown pipeline error", stderr.String()) } } func TestRunPipelineUnknownOnlyLane(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "missing", "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "selected artifact lane") { t.Fatalf("stderr = %q, want selected lane error", stderr.String()) } } func TestRunPipelineInvalidInputPath(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := filepath.Join(t.TempDir(), "missing.json") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "read input") { t.Fatalf("stderr = %q, want input read error", stderr.String()) } } func TestRunPipelineSuccessUsesProductionRegistriesAndFakeLLM(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newFakeRunLLMClient(false) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(client, nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if client.calls != 1 { t.Fatalf("LLM calls = %d, want 1", client.calls) } for _, want := range []string{"dnd-session", "approved=1", "rejected=0", outputDir} { if !strings.Contains(stdout.String(), want) { t.Fatalf("stdout = %q, want substring %q", stdout.String(), want) } } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunPipelineOnlySelectsRequestedLane(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAMLForLanes("dnd-session", "spells", "rituals")) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newFakeRunLLMClient(false) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "spells", "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(client, nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if client.calls != 1 { t.Fatalf("LLM calls = %d, want only selected lane to run once", client.calls) } if !strings.Contains(stdout.String(), "approved=1") { t.Fatalf("stdout = %q, want approved count", stdout.String()) } } func TestRunPipelineLLMFactoryFailure(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), errors.New("factory unavailable")), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "create LLM client") || !strings.Contains(stderr.String(), "factory unavailable") { t.Fatalf("stderr = %q, want LLM factory error", stderr.String()) } } func TestRunPipelineValidationRejectionCompletesSuccessfully(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newFakeRunLLMClient(true) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(client, nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "approved=0") || !strings.Contains(stdout.String(), "rejected=1") { t.Fatalf("stdout = %q, want rejection counts", stdout.String()) } } func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) { profilePath := writeScriptoriumProfileFile(t, "runtime", "http://profile.test/v1", "test-model") configPath := writeTestConfig(t, mvpConfigYAMLWithProfileFile("dnd-session", profilePath)) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newFakeRunLLMClient(false) factory := &recordingLLMFactory{client: client} var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--llm-profile", "runtime", "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: factory.build, }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := strings.Join(factory.profileIDs, ","), "runtime"; got != want { t.Fatalf("factory profile IDs = %q, want %q", got, want) } } func TestRunConfigValidateRejectsUnknownExplicitScriptoriumProfile(t *testing.T) { profilePath := writeScriptoriumProfileFile(t, "known", "http://profile.test/v1", "test-model") configPath := writeTestConfig(t, `version: 2 scriptorium: profile_file: `+profilePath+` pipelines: example: input: fake/input artifacts: events: extract: module: fake/extract llm_profile: missing `) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "Scriptorium profile") || !strings.Contains(stderr.String(), "missing") { t.Fatalf("stderr = %q, want unknown Scriptorium profile", stderr.String()) } } func TestRunPipelineSessionIDFlagRecordsExplicitTrimmedValue(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "dnd-session", "--config", configPath, "--input", inputPath, "--session-id", " external-session ", "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir, }, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest) if got := manifest.Metadata["session_id"]; got != "external-session" { t.Fatalf("manifest metadata = %#v, want trimmed session ID", manifest.Metadata) } } func TestRunPipelineSessionIDDefaultsToParsedSourceID(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) outputDir := t.TempDir() diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir, }, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest) if got := manifest.Metadata["session_id"]; got != "session-alpha" { t.Fatalf("manifest metadata = %#v, want parsed source ID default", manifest.Metadata) } } func TestRunPipelineSessionIDFlagRejectsMissingOrBlankValue(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) inputPath := writeSeriatimInput(t) tests := []struct { name string args []string want string }{ { name: "missing value", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--session-id"}, want: "flag needs an argument", }, { name: "blank value", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--session-id", " \t "}, want: "--session-id must not be empty", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions(test.args, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), test.want) { t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want) } }) } } func TestRunPipelineReferenceFlagBindsUnambiguousSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "roster.yml", "Aria\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "roster=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) refs := resolved.ArtifactLanes[0].ExtractReferences.Bindings want := []pipeline.ReferenceBinding{ {LaneID: "events", SlotName: "roster", Source: referencePath, BindingSource: contracts.ReferenceBindingSourceCLI}, } if !reflect.DeepEqual(refs, want) { t.Fatalf("resolved references = %#v, want %#v", refs, want) } } func TestRunPipelineReferenceFlagBindsLaneQualifiedSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "notes.yml", "Notes\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--only", "events,notes", "--diagnostics-dir", diagnosticsDir, "--reference", "notes.roster=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) events := resolvedArtifactLane(t, resolved, "events") if len(events.ExtractReferences.Bindings) != 0 { t.Fatalf("events references = %#v, want none", events.ExtractReferences.Bindings) } notes := resolvedArtifactLane(t, resolved, "notes") if len(notes.ExtractReferences.Bindings) != 1 || notes.ExtractReferences.Bindings[0].Source != referencePath { t.Fatalf("notes references = %#v, want lane-qualified binding", notes.ExtractReferences.Bindings) } } func TestRunPipelineReferenceFlagBindsChunkQualifiedSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "scenes.md", "Scenes\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "chunk.scene_guide=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "scene_guide"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) refs := resolved.ChunkReferences.Bindings want := []pipeline.ReferenceBinding{ {SlotName: "scene_guide", Source: referencePath, BindingSource: contracts.ReferenceBindingSourceCLI}, } if !reflect.DeepEqual(refs, want) { t.Fatalf("chunk references = %#v, want %#v", refs, want) } } func TestRunPipelineReferenceFlagBindsExplicitExtractSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "roster.yml", "Aria\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "events.extract.roster=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) refs := resolved.ArtifactLanes[0].ExtractReferences.Bindings want := []pipeline.ReferenceBinding{ {LaneID: "events", SlotName: "roster", Source: referencePath, BindingSource: contracts.ReferenceBindingSourceCLI}, } if !reflect.DeepEqual(refs, want) { t.Fatalf("extract references = %#v, want %#v", refs, want) } } func TestRunPipelineReferenceFlagBindsExplicitNormalizeSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "normalize.md", "Normalize\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "events.normalize.notes=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "notes"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) refs := resolved.ArtifactLanes[0].NormalizeReferences.Bindings want := []pipeline.ReferenceBinding{ {LaneID: "events", SlotName: "notes", Source: referencePath, BindingSource: contracts.ReferenceBindingSourceCLI}, } if !reflect.DeepEqual(refs, want) { t.Fatalf("normalize references = %#v, want %#v", refs, want) } } func TestRunPipelineReferenceFlagBindsFlatSlotAcrossOneTarget(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "normalize.md", "Normalize\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "notes=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "notes"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) if refs := resolved.ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 1 || refs[0].Source != referencePath { t.Fatalf("normalize references = %#v, want flat binding", refs) } } func TestRunPipelineReferenceFlagBindsLaneSlotAcrossOneTarget(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := filepath.Join(t.TempDir(), "missing.json") referencePath := writeFile(t, "normalize.md", "Normalize\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "events.notes=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "notes"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) if refs := resolved.ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 1 || refs[0].Source != referencePath { t.Fatalf("normalize references = %#v, want lane-qualified binding", refs) } } func TestRunPipelineReferenceFlagRejectsAmbiguousFlatSlot(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "roster=./roster.yml", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "multiple selected targets") || !strings.Contains(stderr.String(), "events.extract.roster") || !strings.Contains(stderr.String(), "notes.extract.roster") { t.Fatalf("stderr = %q, want ambiguous reference error", stderr.String()) } } func TestRunPipelineReferenceFlagRejectsAmbiguousFlatSlotAcrossChunkAndExtract(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "context=./context.md", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context"}, }, }, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context"}, }, }, ), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "multiple selected targets") || !strings.Contains(stderr.String(), "chunk.context") || !strings.Contains(stderr.String(), "events.extract.context") { t.Fatalf("stderr = %q, want ambiguous chunk/extract reference error", stderr.String()) } } func TestRunPipelineReferenceFlagRejectsAmbiguousLaneSlotAcrossExtractAndNormalize(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--reference", "events.context=./context.md", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context"}, }, }, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context"}, }, }, ), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "multiple selected targets in lane") || !strings.Contains(stderr.String(), "events.extract.context") || !strings.Contains(stderr.String(), "events.normalize.context") { t.Fatalf("stderr = %q, want ambiguous lane reference error", stderr.String()) } } func TestRunPipelineReferenceFlagsRejectMalformedValues(t *testing.T) { tests := []struct { name string args []string want string }{ {name: "missing equals", args: []string{"--reference", "roster"}, want: "slot=path"}, {name: "empty path", args: []string{"--reference", "roster="}, want: "path must not be empty"}, {name: "empty slot", args: []string{"--reference", "=./roster.yml"}, want: "slot must not be empty"}, {name: "unsupported explicit stage", args: []string{"--reference", "a.b.c=./roster.yml"}, want: "lane.extract.slot or lane.normalize.slot"}, {name: "too many selector parts", args: []string{"--reference", "a.b.c.d=./roster.yml"}, want: "slot, chunk.slot, lane.slot, lane.extract.slot, or lane.normalize.slot"}, {name: "unbind with equals", args: []string{"--without-reference", "roster=./roster.yml"}, want: "without =path"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer args := []string{"run", "example", "--config", configPath, "--input", inputPath} args = append(args, test.args...) code := RunWithOptions(args, &stdout, &stderr, Options{Catalog: fakeCatalog(t)}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), test.want) { t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want) } }) } } func TestRunPipelineWithoutReferenceRemovesConfigBindingsForEligibleTargets(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLWithPipelineReferences("example", "events", map[string]string{ "context": "./config-context.md", "notes": "./config-notes.md", "roster": "./config-roster.yml", })) inputPath := filepath.Join(t.TempDir(), "missing.json") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--without-reference", "chunk.context", "--without-reference", "events.extract.roster", "--without-reference", "events.normalize.notes", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context"}, }, }, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "notes"}, }, }, ), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) if refs := resolved.ChunkReferences.Bindings; len(refs) != 0 { t.Fatalf("chunk references = %#v, want none", refs) } if refs := resolved.ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 { t.Fatalf("extract references = %#v, want none", refs) } if refs := resolved.ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 0 { t.Fatalf("normalize references = %#v, want none", refs) } } func TestRunPipelineWithoutReferenceRemovesOptionalConfigBinding(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLWithReferences("example", "events", map[string]string{"roster": "./config-roster.yml"})) inputPath := filepath.Join(t.TempDir(), "missing.json") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--without-reference", "roster", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 || !strings.Contains(stderr.String(), "read input") { t.Fatalf("RunWithOptions() code = %d stderr=%q, want read input failure after resolution", code, stderr.String()) } resolved := readResolvedPipeline(t, diagnosticsDir) if refs := resolved.ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 { t.Fatalf("references = %#v, want unbound optional slot", refs) } } func TestRunPipelineWithoutReferenceFailsWhenRequiredChunkSlotWouldBeMissing(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLWithPipelineReferences("example", "events", map[string]string{"context": "./config-context.md"})) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--without-reference", "chunk.context", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "context", Required: true}, }, }), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "required reference slot") || !strings.Contains(stderr.String(), "context") { t.Fatalf("stderr = %q, want required chunk reference error", stderr.String()) } } func TestRunPipelineWithoutReferenceFailsWhenRequiredNormalizeSlotWouldBeMissing(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLWithPipelineReferences("example", "events", map[string]string{"notes": "./config-notes.md"})) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--without-reference", "events.normalize.notes", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "notes", Required: true}, }, }), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "required reference slot") || !strings.Contains(stderr.String(), "notes") { t.Fatalf("stderr = %q, want required normalize reference error", stderr.String()) } } func TestRunPipelineReferenceFlagRejectsUnselectedLaneSelector(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) inputPath := writeSeriatimInput(t) referencePath := writeFile(t, "notes.yml", "Notes\n") diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--only", "events", "--diagnostics-dir", diagnosticsDir, "--reference", "notes.extract.roster=" + referencePath, }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), `reference lane "notes" is not selected`) { t.Fatalf("stderr = %q, want unselected lane error", stderr.String()) } } func TestRunPipelineWithoutReferenceFailsWhenRequiredSlotWouldBeMissing(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLWithReferences("example", "events", map[string]string{"roster": "./config-roster.yml"})) inputPath := writeSeriatimInput(t) diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{ "run", "example", "--config", configPath, "--input", inputPath, "--diagnostics-dir", diagnosticsDir, "--without-reference", "roster", }, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster", Required: true}, }, }), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "required reference slot") || !strings.Contains(stderr.String(), "roster") { t.Fatalf("stderr = %q, want required reference error", stderr.String()) } } func TestRunPipelineWritesDurableOutputFiles(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "always")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } runOutputDir := onlyChildDir(t, outputDir) for _, name := range []string{ "index.json", "manifest.json", "artifacts/dnd.spell_cast.json", "rejected.json", "warnings.json", } { if _, err := os.Stat(filepath.Join(runOutputDir, filepath.FromSlash(name))); err != nil { t.Fatalf("expected output file %q: %v", name, err) } } if !strings.Contains(stdout.String(), runOutputDir) { t.Fatalf("stdout = %q, want output path %q", stdout.String(), runOutputDir) } assertNoTemporaryFiles(t, runOutputDir) } func TestRunPipelineReferenceBytesProduceDistinctManifests(t *testing.T) { run := func(t *testing.T, referenceText string) artifacts.RunManifest { t.Helper() diagnosticsDir := t.TempDir() outputDir := t.TempDir() configDir := t.TempDir() referencePath := filepath.Join(configDir, "roster.txt") if err := os.WriteFile(referencePath, []byte(referenceText), 0o644); err != nil { t.Fatalf("write reference: %v", err) } configPath := filepath.Join(configDir, "config.yml") if err := os.WriteFile(configPath, []byte(testConfigYAMLWithReferencesAndDiagnostics("example", "events", diagnosticsDir, map[string]string{"roster": "roster.txt"})), 0o644); err != nil { t.Fatalf("write config: %v", err) } inputPath := writeFile(t, "source.txt", "source text") var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "example", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }), Registries: fakeExecutionRegistries(t), LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest) if len(manifest.References) != 1 { t.Fatalf("manifest references = %#v, want one entry", manifest.References) } if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:source"}) { t.Fatalf("source digests = %#v, want source-only digest", manifest.SourceDigests) } var resolvedReferences []artifacts.ReferenceProvenance readJSONFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactResolvedReferences), &resolvedReferences) if !reflect.DeepEqual(resolvedReferences, manifest.References) { t.Fatalf("resolved references = %#v, want manifest references %#v", resolvedReferences, manifest.References) } runManifestJSON := string(readFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactRunManifest))) if strings.Contains(runManifestJSON, "source text") || strings.Contains(runManifestJSON, referenceText) { t.Fatalf("run manifest diagnostics contains raw prompt material: %s", runManifestJSON) } resolvedReferenceJSON := string(readFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactResolvedReferences))) if strings.Contains(resolvedReferenceJSON, referenceText) || strings.Contains(resolvedReferenceJSON, "content") { t.Fatalf("resolved references diagnostics contains content: %s", resolvedReferenceJSON) } return manifest } first := run(t, "first roster") second := run(t, "second roster") if first.References[0].Digest == second.References[0].Digest { t.Fatalf("reference digests match for different bytes: %q", first.References[0].Digest) } if first.PipelineDigest != second.PipelineDigest { t.Fatalf("pipeline digests differ = %q vs %q, want reference bytes outside pipeline identity", first.PipelineDigest, second.PipelineDigest) } } func TestRunPipelineRejectsUnsafeOutputFileName(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "always")) inputPath := writeSeriatimInput(t) registries := registriesWithOutput(t, unsafeOutputEncoder{}) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ Registries: registries, LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "output file name") { t.Fatalf("stderr = %q, want unsafe output file error", stderr.String()) } runDir := onlyChildDir(t, diagnosticsDir) if got := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactErrorLog))); !strings.Contains(got, "output file name") { t.Fatalf("error log = %q, want unsafe output file error", got) } if _, err := os.Stat(filepath.Join(runDir, diagnostics.ArtifactRunManifest)); err != nil { t.Fatalf("expected diagnostics manifest after unsafe output file failure: %v", err) } } func TestRunPipelineWritesDiagnosticsArtifactsWhenDurableOutputWriteFails(t *testing.T) { diagnosticsDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "always")) inputPath := writeSeriatimInput(t) outputRootFile := writeFile(t, "not-a-directory", "occupied") var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputRootFile}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "create output directory") { t.Fatalf("stderr = %q, want output directory error", stderr.String()) } runDir := onlyChildDir(t, diagnosticsDir) for _, name := range []string{ diagnostics.ArtifactRunManifest, diagnostics.ArtifactWarnings, diagnostics.ArtifactRunReport, diagnostics.ArtifactErrorLog, } { if _, err := os.Stat(filepath.Join(runDir, name)); err != nil { t.Fatalf("expected diagnostics artifact %q after durable output write failure: %v", name, err) } } } func TestRunPipelineWritesDiagnosticsArtifactsOnSuccess(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "always")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } runDir := onlyChildDir(t, diagnosticsDir) for _, name := range []string{ diagnostics.ArtifactInvocationMetadata, diagnostics.ArtifactEffectiveConfig, diagnostics.ArtifactResolvedPipeline, diagnostics.ArtifactRunManifest, diagnostics.ArtifactRunReport, diagnostics.ArtifactWarnings, } { if _, err := os.Stat(filepath.Join(runDir, name)); err != nil { t.Fatalf("expected diagnostics artifact %q: %v", name, err) } } report := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactRunReport))) if !strings.Contains(report, `"approved_count": 1`) || !strings.Contains(report, `"validation_status": "approved"`) || !strings.Contains(report, outputDir) { t.Fatalf("unexpected run report: %s", report) } } func TestRunPipelineWritesErrorLogAfterDiagnosticsCreation(t *testing.T) { diagnosticsDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "always")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), errors.New("factory unavailable")), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } runDir := onlyChildDir(t, diagnosticsDir) if got := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactErrorLog))); !strings.Contains(got, "factory unavailable") { t.Fatalf("error log = %q, want factory error", got) } } func TestRunPipelineRetentionNeverRemovesSuccessfulWarningFreeDiagnostics(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "never")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if entries := childDirs(t, diagnosticsDir); len(entries) != 0 { t.Fatalf("diagnostics run dirs = %v, want none", entries) } } func TestRunPipelineWarningsAreDiagnosedAndReported(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", diagnosticsDir, "auto")) inputPath := writeSeriatimInput(t) registries := registriesWithOutput(t, warningOutputEncoder{}) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ Registries: registries, LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stderr.String(), "1 warning") { t.Fatalf("stderr = %q, want warning count", stderr.String()) } runDir := onlyChildDir(t, diagnosticsDir) warnings := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactWarnings))) if !strings.Contains(warnings, "synthetic_warning") { t.Fatalf("warnings artifact = %q, want synthetic warning", warnings) } } func TestRunPipelineDiagnosticsDirFlagOverridesConfig(t *testing.T) { configDiagnosticsDir := t.TempDir() overrideDiagnosticsDir := t.TempDir() outputDir := t.TempDir() configPath := writeTestConfig(t, mvpConfigYAMLWithDiagnostics("dnd-session", configDiagnosticsDir, "always")) inputPath := writeSeriatimInput(t) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", overrideDiagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if entries := childDirs(t, configDiagnosticsDir); len(entries) != 0 { t.Fatalf("config diagnostics dir entries = %v, want none", entries) } if entries := childDirs(t, overrideDiagnosticsDir); len(entries) != 1 { t.Fatalf("override diagnostics dir entries = %v, want one run dir", entries) } } func TestExampleFixtureConfigValidateAndPipelinesList(t *testing.T) { configPath := fixturePath(t, "examples/dnd-spells.config.yml") t.Run("validate configured pipeline", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "dnd-session") { t.Fatalf("stdout = %q, want pipeline ID", stdout.String()) } }) t.Run("list configured pipelines", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "dnd-session\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } }) } func TestExampleFixtureRunWritesExpectedJSON(t *testing.T) { configPath := fixturePath(t, "examples/dnd-spells.config.yml") inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json") outputDir := t.TempDir() diagnosticsDir := t.TempDir() var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } runOutputDir := onlyChildDir(t, outputDir) if !strings.Contains(stdout.String(), runOutputDir) { t.Fatalf("stdout = %q, want output path %q", stdout.String(), runOutputDir) } var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(runOutputDir, "manifest.json"), &manifest) if manifest.PipelineID != "dnd-session" { t.Fatalf("manifest pipeline ID = %q, want dnd-session", manifest.PipelineID) } if manifest.PipelineDigest == "" { t.Fatal("manifest pipeline digest is empty") } if manifest.ValidationStatus != "approved" { t.Fatalf("validation status = %q, want approved", manifest.ValidationStatus) } if len(manifest.ArtifactLanes) != 1 { t.Fatalf("artifact lanes = %#v, want one lane", manifest.ArtifactLanes) } extractorMetadata, ok := manifest.ArtifactLanes[0].Metadata["extractor"].(map[string]any) if !ok { t.Fatalf("extractor metadata = %#v, want object", manifest.ArtifactLanes[0].Metadata) } if extractorMetadata["prompt_id"] != "dnd.spells" || extractorMetadata["response_schema_key"] != "dnd_spells" { t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata) } var artifactFile struct { ArtifactType string `json:"artifact_type"` Artifacts []artifacts.Artifact `json:"artifacts"` } readJSONFile(t, filepath.Join(runOutputDir, "artifacts", "dnd.spell_cast.json"), &artifactFile) if artifactFile.ArtifactType != "dnd.spell_cast" || len(artifactFile.Artifacts) != 1 { t.Fatalf("artifact file = %#v, want one spell artifact", artifactFile) } var payload struct { Caster string `json:"caster"` Spell string `json:"spell"` Effect string `json:"effect"` } if err := json.Unmarshal(artifactFile.Artifacts[0].Payload, &payload); err != nil { t.Fatalf("unmarshal spell payload: %v", err) } if payload.Caster != "Aria" || payload.Spell != "Cure Wounds" || payload.Effect == "" { t.Fatalf("payload = %#v, want deterministic spell output", payload) } if len(artifactFile.Artifacts[0].SourceRefs) != 1 { t.Fatalf("source refs = %#v, want one source ref", artifactFile.Artifacts[0].SourceRefs) } ref := artifactFile.Artifacts[0].SourceRefs[0] if ref.SourceID != "session-alpha" || ref.StartUnitID != "seg-001" || ref.EndUnitID != "seg-001" { t.Fatalf("source ref = %#v, want fixture source ref", ref) } warnings := string(readFile(t, filepath.Join(runOutputDir, "warnings.json"))) if !strings.Contains(warnings, `"warnings": []`) { t.Fatalf("warnings output = %s, want empty warnings", warnings) } } func TestExampleFixtureRunWithDNDScenesRecordsChunkerAndWarnings(t *testing.T) { configPath := writeTestConfig(t, mvpConfigYAMLWithChunk("dnd-session", scenes.Key, "dnd/spells")) inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json") outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newSceneRunLLMClient("Scene boundary was ambiguous.") var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(client, nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if client.calls != 2 { t.Fatalf("LLM calls = %d, want chunking and extraction calls", client.calls) } if !strings.Contains(stderr.String(), "1 warning") { t.Fatalf("stderr = %q, want warning count", stderr.String()) } runOutputDir := onlyChildDir(t, outputDir) manifestBytes := readFile(t, filepath.Join(runOutputDir, "manifest.json")) var manifest artifacts.RunManifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { t.Fatalf("unmarshal manifest: %v", err) } if manifest.Chunker != scenes.Key { t.Fatalf("manifest chunker = %q, want %q", manifest.Chunker, scenes.Key) } chunkerMetadata := manifest.ModuleMetadata["chunker"] if chunkerMetadata == nil { t.Fatalf("module metadata chunker = %#v, want object", manifest.ModuleMetadata["chunker"]) } wantMetadataKeys := []string{ "prompt_id", "prompt_version", "prompt_sha256", "response_schema_key", "response_schema_id", "response_schema_name", "response_schema_version", "response_schema_sha256", } if len(chunkerMetadata) != len(wantMetadataKeys) { t.Fatalf("chunker metadata keys = %#v, want %d keys", chunkerMetadata, len(wantMetadataKeys)) } for _, key := range wantMetadataKeys { value, ok := chunkerMetadata[key] if !ok { t.Fatalf("chunker metadata missing key %q: %#v", key, chunkerMetadata) } if _, ok := value.(string); !ok { t.Fatalf("chunker metadata[%q] = %#v, want string", key, value) } } for _, forbidden := range []string{"prompt", "schema", "source", "text", "payload", "api_key", "secret", "token"} { if _, ok := chunkerMetadata[forbidden]; ok { t.Fatalf("chunker metadata leaked forbidden key %q: %#v", forbidden, chunkerMetadata) } } for _, forbidden := range []string{"Source document ID:", "Aria casts Cure Wounds.", "spell_casts", "Scene boundary was ambiguous."} { if strings.Contains(string(manifestBytes), forbidden) { t.Fatalf("manifest leaked %q: %s", forbidden, manifestBytes) } } warnings := string(readFile(t, filepath.Join(runOutputDir, "warnings.json"))) if !strings.Contains(warnings, "scene_boundary_caveat") || !strings.Contains(warnings, "Scene boundary was ambiguous.") { t.Fatalf("warnings output = %s, want scene boundary caveat", warnings) } } func TestExampleFixtureRunOnlySpells(t *testing.T) { configPath := fixturePath(t, "examples/dnd-spells.config.yml") inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json") outputDir := t.TempDir() diagnosticsDir := t.TempDir() client := newFakeRunLLMClient(false) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "spells", "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir}, &stdout, &stderr, Options{ LLMClientFactory: fakeLLMFactory(client, nil), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if client.calls != 1 { t.Fatalf("LLM calls = %d, want selected lane once", client.calls) } if _, err := os.Stat(filepath.Join(onlyChildDir(t, outputDir), "manifest.json")); err != nil { t.Fatalf("expected manifest output: %v", err) } } func TestExampleFixtureFailureCoverage(t *testing.T) { configPath := fixturePath(t, "examples/dnd-spells.config.yml") inputPath := fixturePath(t, "examples/seriatim-minimal-transcript.json") tests := []struct { name string args []string factory LLMClientFactory wantCode int wantStderr string wantOutputStatus string }{ { name: "missing config", args: []string{"config", "validate", "--config", filepath.Join(filepath.Dir(configPath), "missing.yml"), "--pipeline", "dnd-session"}, wantCode: 1, wantStderr: "config file", }, { name: "unknown pipeline", args: []string{"run", "missing", "--config", configPath, "--input", inputPath}, factory: fakeLLMFactory(newFakeRunLLMClient(false), nil), wantCode: 1, wantStderr: "not configured", }, { name: "invalid Seriatim input", args: []string{"run", "dnd-session", "--config", configPath, "--input", fixturePath(t, "internal/cli/testdata/invalid-seriatim-empty-segments.json")}, factory: fakeLLMFactory(newFakeRunLLMClient(false), nil), wantCode: 1, wantStderr: "segments must not be empty", }, { name: "invalid only lane", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--only", "missing"}, factory: fakeLLMFactory(newFakeRunLLMClient(false), nil), wantCode: 1, wantStderr: "selected artifact lane", }, { name: "fake LLM failure", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, factory: fakeLLMFactory(newErrorRunLLMClient(errors.New("completion unavailable")), nil), wantCode: 1, wantStderr: "completion unavailable", }, { name: "malformed LLM response", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, factory: fakeLLMFactory(newMalformedRunLLMClient(), nil), wantCode: 1, wantStderr: "spell_casts", }, { name: "invalid source reference rejection", args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath}, factory: fakeLLMFactory(newFakeRunLLMClient(true), nil), wantCode: 0, wantOutputStatus: "rejected", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { outputDir := t.TempDir() diagnosticsDir := t.TempDir() args := append([]string(nil), test.args...) if args[0] == "run" { args = append(args, "--output-dir", outputDir, "--diagnostics-dir", diagnosticsDir) } factory := test.factory if factory == nil { factory = fakeLLMFactory(newFakeRunLLMClient(false), nil) } var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions(args, &stdout, &stderr, Options{LLMClientFactory: factory}) if code != test.wantCode { t.Fatalf("RunWithOptions() code = %d, want %d; stderr=%q", code, test.wantCode, stderr.String()) } if test.wantStderr != "" && !strings.Contains(stderr.String(), test.wantStderr) { t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.wantStderr) } if test.wantOutputStatus != "" { runOutputDir := onlyChildDir(t, outputDir) var manifest artifacts.RunManifest readJSONFile(t, filepath.Join(runOutputDir, "manifest.json"), &manifest) if manifest.ValidationStatus != test.wantOutputStatus { t.Fatalf("validation status = %q, want %q", manifest.ValidationStatus, test.wantOutputStatus) } rejected := string(readFile(t, filepath.Join(runOutputDir, "rejected.json"))) if !strings.Contains(rejected, "invalid_source_ref") { t.Fatalf("rejected output = %s, want invalid source ref rejection", rejected) } } }) } } func writeTestConfig(t *testing.T, content string) string { t.Helper() return writeFile(t, "config.yml", content) } func writeFile(t *testing.T, name string, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } return path } func fixturePath(t *testing.T, name string) string { t.Helper() path := filepath.Join("..", "..", filepath.FromSlash(name)) if _, err := os.Stat(path); err != nil { t.Fatalf("fixture %q is not available at %q: %v", name, path, err) } return path } func testConfigYAML(pipelineID string, laneIDs ...string) string { return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs}) } func testConfigYAMLForPipelines(pipelines map[string][]string) string { var b strings.Builder b.WriteString("version: 2\n") b.WriteString("pipelines:\n") for pipelineID, laneIDs := range pipelines { b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: fake/input\n") b.WriteString(" artifacts:\n") for _, laneID := range laneIDs { b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: fake/extract\n") } } return b.String() } func testConfigYAMLWithReferences(pipelineID string, laneID string, references map[string]string) string { var b strings.Builder b.WriteString("version: 2\n") b.WriteString("pipelines:\n") b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: fake/input\n") b.WriteString(" artifacts:\n") b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: fake/extract\n") b.WriteString(" references:\n") keys := make([]string, 0, len(references)) for key := range references { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { b.WriteString(" " + key + ": " + references[key] + "\n") } return b.String() } func testConfigYAMLWithPipelineReferences(pipelineID string, laneID string, references map[string]string) string { var b strings.Builder b.WriteString("version: 2\n") b.WriteString("pipelines:\n") b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: fake/input\n") b.WriteString(" references:\n") keys := make([]string, 0, len(references)) for key := range references { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { b.WriteString(" " + key + ": " + references[key] + "\n") } b.WriteString(" artifacts:\n") b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: fake/extract\n") return b.String() } func testConfigYAMLWithReferencesAndDiagnostics(pipelineID string, laneID string, diagnosticsDir string, references map[string]string) string { var b strings.Builder b.WriteString("version: 2\n") b.WriteString("diagnostics:\n") b.WriteString(" work_dir: " + diagnosticsDir + "\n") b.WriteString(" retention: always\n") b.WriteString("pipelines:\n") b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: fake/input\n") b.WriteString(" artifacts:\n") b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: fake/extract\n") b.WriteString(" references:\n") keys := make([]string, 0, len(references)) for key := range references { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { b.WriteString(" " + key + ": " + references[key] + "\n") } return b.String() } func mvpConfigYAML(pipelineID string, extractor string) string { return `version: 2 pipelines: ` + pipelineID + `: input: seriatim artifacts: spells: extract: ` + extractor + ` ` } func mvpConfigYAMLWithChunk(pipelineID string, chunker string, extractor string) string { return `version: 2 pipelines: ` + pipelineID + `: input: seriatim chunk: ` + chunker + ` artifacts: spells: extract: ` + extractor + ` ` } func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string { var b strings.Builder b.WriteString("version: 2\n") b.WriteString("pipelines:\n") b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: seriatim\n") b.WriteString(" artifacts:\n") for _, laneID := range laneIDs { b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: dnd/spells\n") } return b.String() } func mvpConfigYAMLWithProfileFile(pipelineID string, profileFile string) string { return `version: 2 scriptorium: profile_file: ` + profileFile + ` pipelines: ` + pipelineID + `: input: seriatim artifacts: spells: extract: dnd/spells ` } func writeScriptoriumProfileFile(t *testing.T, id string, endpoint string, model string) string { t.Helper() return writeFile(t, id+".profile.yml", `id: `+id+` endpoint: `+endpoint+` model: `+model+` `) } func mvpConfigYAMLWithDiagnostics(pipelineID, diagnosticsDir, retention string) string { return `version: 2 diagnostics: work_dir: ` + diagnosticsDir + ` retention: ` + retention + ` pipelines: ` + pipelineID + `: input: seriatim artifacts: spells: extract: dnd/spells ` } func writeSeriatimInput(t *testing.T) string { t.Helper() return writeFile(t, "source.json", `{ "metadata": { "id": "session-alpha" }, "segments": [ { "id": "seg-001", "start": 0, "end": 1, "speaker": "Aria", "text": "Aria casts Cure Wounds." } ] }`) } type fakeRunLLMClient struct { invalidSourceRef bool calls int err error payload map[string]any sceneCaveat string } func newFakeRunLLMClient(invalidSourceRef bool) *fakeRunLLMClient { return &fakeRunLLMClient{invalidSourceRef: invalidSourceRef} } func newErrorRunLLMClient(err error) *fakeRunLLMClient { return &fakeRunLLMClient{err: err} } func newMalformedRunLLMClient() *fakeRunLLMClient { return &fakeRunLLMClient{payload: map[string]any{}} } func newSceneRunLLMClient(caveat string) *fakeRunLLMClient { return &fakeRunLLMClient{sceneCaveat: caveat} } func (client *fakeRunLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { client.calls++ if client.err != nil { return contracts.StructuredCompletionResponse{}, client.err } if req.StageName == scenes.Key && client.payload == nil { payload := map[string]any{ "scenes": []map[string]any{ { "start_unit_id": "seg-001", "end_unit_id": "seg-002", "short_title": "Opening spell", "primary_mode": "Narrative", "main_participants": []string{"Aria"}, "summary": "Aria casts a spell.", "boundary_note": "The provided source units form one scene.", "boundary_confidence": "High", }, }, "boundary_caveats": []string{}, } if client.sceneCaveat != "" { payload["boundary_caveats"] = []string{client.sceneCaveat} } encoded, err := json.Marshal(payload) if err != nil { return contracts.StructuredCompletionResponse{}, err } if err := json.Unmarshal(encoded, out); err != nil { return contracts.StructuredCompletionResponse{}, err } return contracts.StructuredCompletionResponse{Content: encoded}, nil } startUnitID := "seg-001" if client.invalidSourceRef { startUnitID = "missing-segment" } payload := client.payload if payload == nil { payload = map[string]any{ "spell_casts": []map[string]any{ { "caster": "Aria", "spell": "Cure Wounds", "effect": "Heals a wounded ally.", "narrative_description": "Aria casts Cure Wounds.", "source_refs": []map[string]string{ { "source_id": "session-alpha", "start_unit_id": startUnitID, "end_unit_id": "seg-001", }, }, }, }, } } encoded, err := json.Marshal(payload) if err != nil { return contracts.StructuredCompletionResponse{}, err } if err := json.Unmarshal(encoded, out); err != nil { return contracts.StructuredCompletionResponse{}, err } return contracts.StructuredCompletionResponse{Content: encoded}, nil } func fakeLLMFactory(client contracts.StructuredLLMClient, err error) LLMClientFactory { return func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) { if err != nil { return nil, nil, err } return client, []artifacts.LLMProfileManifest{{ID: strings.TrimSpace(profileID)}}, nil } } type recordingLLMFactory struct { client contracts.StructuredLLMClient profileIDs []string } func (factory *recordingLLMFactory) build(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) { factory.profileIDs = append(factory.profileIDs, strings.TrimSpace(profileID)) return factory.client, []artifacts.LLMProfileManifest{{ID: strings.TrimSpace(profileID)}}, nil } type unsafeOutputEncoder struct{} func (unsafeOutputEncoder) Key() string { return "json" } func (unsafeOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { return contracts.OutputResult{ Files: []contracts.OutputFile{ { Name: "../escape.json", ContentType: "application/json", Bytes: []byte("{}\n"), }, }, }, nil } type warningOutputEncoder struct{} func (warningOutputEncoder) Key() string { return "json" } func (warningOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { result, err := jsonoutput.New().Encode(ctx, req) if err != nil { return contracts.OutputResult{}, err } result.Warnings = append(result.Warnings, contracts.Warning{ Scope: "output", ReasonCode: "synthetic_warning", Message: "synthetic output warning", }) return result, nil } func registriesWithOutput(t *testing.T, encoder contracts.OutputEncoder) pipeline.Registries { t.Helper() registries, err := productionRegistries() if err != nil { t.Fatalf("productionRegistries: %v", err) } outputs := pipeline.NewOutputEncoderRegistry() if err := outputs.RegisterWithSpec(jsonoutput.ModuleSpec(), func() (contracts.OutputEncoder, error) { return encoder, nil }); err != nil { t.Fatalf("register test output encoder: %v", err) } registries.Outputs = outputs return registries } func fakeExecutionRegistries(t *testing.T) pipeline.Registries { t.Helper() inputs := pipeline.NewInputAdapterRegistry() chunkers := pipeline.NewChunkerRegistry() extractors := pipeline.NewExtractorRegistry() mergers := pipeline.NewMergerRegistry() normalizers := pipeline.NewNormalizerRegistry() outputs := pipeline.NewOutputEncoderRegistry() if err := inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) { return fakeRunInputAdapter{}, nil }); err != nil { t.Fatalf("register fake input: %v", err) } if err := chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}, func() (contracts.Chunker, error) { return fakeRunChunker{}, nil }); err != nil { t.Fatalf("register fake chunker: %v", err) } if err := extractors.RegisterWithSpec(pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, }, }, func() (contracts.Extractor, error) { return fakeRunExtractor{}, nil }); err != nil { t.Fatalf("register fake extractor: %v", err) } if err := mergers.RegisterWithSpec(pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}, func() (contracts.Merger, error) { return fakeRunMerger{}, nil }); err != nil { t.Fatalf("register fake merger: %v", err) } if err := normalizers.RegisterWithSpec(pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer, error) { return fakeRunNormalizer{}, nil }); err != nil { t.Fatalf("register fake normalizer: %v", err) } if err := jsonoutput.Register(outputs); err != nil { t.Fatalf("register json output: %v", err) } return pipeline.Registries{ Inputs: inputs, Chunkers: chunkers, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, Outputs: outputs, } } type fakeRunInputAdapter struct{} func (fakeRunInputAdapter) Key() string { return "fake/input" } func (fakeRunInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { return &source.SourceDocument{ ID: "source", Kind: "text", Format: "test", Digest: "sha256:source", Units: []source.SourceUnit{ {ID: "unit-1", Kind: "text", Text: string(req.Raw)}, }, }, nil } type fakeRunChunker struct{} func (fakeRunChunker) Key() string { return "generic" } func (fakeRunChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (fakeRunChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { return contracts.ChunkResult{ Chunks: []contracts.SourceChunk{ {ID: "chunk-1", SourceID: req.Source.ID, Index: 0, Units: req.Source.Units}, }, }, nil } type fakeRunExtractor struct{} func (fakeRunExtractor) Key() string { return "fake/extract" } func (fakeRunExtractor) ArtifactType() string { return "fake.artifact" } func (fakeRunExtractor) SchemaVersion() string { return "v1" } func (fakeRunExtractor) ReferenceSlots() []contracts.ReferenceSlot { return []contracts.ReferenceSlot{{Name: "roster"}} } func (fakeRunExtractor) Validators() []contracts.Validator { return nil } func (fakeRunExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { return contracts.ExtractionResult{ Candidates: []artifacts.ArtifactCandidate{ { Payload: []byte(`{"value":true}`), SourceRefs: []source.SourceRef{ {SourceID: "source", StartUnitID: "unit-1", EndUnitID: "unit-1"}, }, }, }, }, nil } type fakeRunMerger struct{} func (fakeRunMerger) Key() string { return "appendorder" } func (fakeRunMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { var candidates []artifacts.ArtifactCandidate for _, chunkArtifacts := range req.ChunkArtifacts { candidates = append(candidates, chunkArtifacts.Candidates...) } return contracts.MergeResult{Candidates: candidates}, nil } type fakeRunNormalizer struct{} func (fakeRunNormalizer) Key() string { return "noop" } func (fakeRunNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (fakeRunNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { return contracts.NormalizeResult{Candidates: append([]artifacts.ArtifactCandidate(nil), req.Candidates...)}, nil } func onlyChildDir(t *testing.T, root string) string { t.Helper() children := childDirs(t, root) if len(children) != 1 { t.Fatalf("child dirs under %q = %v, want one", root, children) } return children[0] } func childDirs(t *testing.T, root string) []string { t.Helper() entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { return nil } t.Fatalf("read dir %q: %v", root, err) } var dirs []string for _, entry := range entries { if entry.IsDir() { dirs = append(dirs, filepath.Join(root, entry.Name())) } } return dirs } func readFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %q: %v", path, err) } return data } func readJSONFile(t *testing.T, path string, out any) { t.Helper() if err := json.Unmarshal(readFile(t, path), out); err != nil { t.Fatalf("unmarshal %q: %v", path, err) } } func readResolvedPipeline(t *testing.T, diagnosticsDir string) pipeline.ResolvedPipeline { t.Helper() runDir := onlyChildDir(t, diagnosticsDir) var resolved pipeline.ResolvedPipeline readJSONFile(t, filepath.Join(runDir, diagnostics.ArtifactResolvedPipeline), &resolved) return resolved } func resolvedArtifactLane(t *testing.T, resolved pipeline.ResolvedPipeline, laneID string) pipeline.ResolvedArtifactLane { t.Helper() for _, lane := range resolved.ArtifactLanes { if lane.ID == laneID { return lane } } t.Fatalf("lane %q not found in resolved pipeline", laneID) return pipeline.ResolvedArtifactLane{} } func assertNoTemporaryFiles(t *testing.T, root string) { t.Helper() if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } if strings.Contains(entry.Name(), ".tmp-") { t.Fatalf("temporary file remains after success: %s", path) } return nil }); err != nil { t.Fatalf("walk output dir %q: %v", root, err) } } func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.ModuleCatalog { t.Helper() inputs := pipeline.NewInputAdapterRegistry() chunkers := pipeline.NewChunkerRegistry() extractors := pipeline.NewExtractorRegistry() mergers := pipeline.NewMergerRegistry() normalizers := pipeline.NewNormalizerRegistry() validators := pipeline.NewValidatorRegistry() outputs := pipeline.NewOutputEncoderRegistry() specs := map[string]pipeline.ModuleSpec{ "fake/input": {Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, "generic": {Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}, "fake/extract": {Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}}, "appendorder": {Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}, "noop": {Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}, "json": {Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}}, } for _, override := range overrides { specs[override.Key] = override } mustRegisterInput(t, inputs, specs["fake/input"]) mustRegisterChunker(t, chunkers, specs["generic"]) mustRegisterExtractor(t, extractors, specs["fake/extract"]) mustRegisterMerger(t, mergers, specs["appendorder"]) mustRegisterNormalizer(t, normalizers, specs["noop"]) mustRegisterOutput(t, outputs, specs["json"]) return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, Validators: validators, Outputs: outputs, } } func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil { t.Fatalf("register input: %v", err) } } func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil { t.Fatalf("register chunker: %v", err) } } func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil { t.Fatalf("register extractor: %v", err) } } func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil { t.Fatalf("register merger: %v", err) } } func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil { t.Fatalf("register normalizer: %v", err) } } func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { t.Fatalf("register output: %v", err) } } func mapLookup(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { value, ok := values[key] return value, ok } }