1582 lines
53 KiB
Go
1582 lines
53 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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/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: 2\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 TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) {
|
|
configPath := writeTestConfig(t, `version: 1
|
|
llm_profiles:
|
|
default:
|
|
api_key_env: NOTARIUS_TEST_API_KEY
|
|
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{
|
|
LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}),
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, 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 TestProductionLLMClientFactoryRejectsMissingProfile(t *testing.T) {
|
|
cfg := config.Default()
|
|
|
|
_, _, err := productionLLMClientFactory(context.Background(), cfg, "missing")
|
|
if err == nil {
|
|
t.Fatal("productionLLMClientFactory() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), "LLM profile") || !strings.Contains(err.Error(), "missing") {
|
|
t.Fatalf("error = %q, want missing profile context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestProductionLLMClientFactoryRejectsInvalidProfile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
profile config.LLMProfile
|
|
want string
|
|
}{
|
|
{
|
|
name: "unsupported provider",
|
|
profile: config.LLMProfile{Provider: "other", BaseURL: "https://example.test", Model: "model"},
|
|
want: "not supported",
|
|
},
|
|
{
|
|
name: "missing base url",
|
|
profile: config.LLMProfile{Provider: "openai-compatible", Model: "model"},
|
|
want: "base URL",
|
|
},
|
|
{
|
|
name: "missing model",
|
|
profile: config.LLMProfile{Provider: "openai-compatible", BaseURL: "https://example.test"},
|
|
want: "model",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.LLMProfiles = map[string]config.LLMProfile{"default": test.profile}
|
|
|
|
_, _, err := productionLLMClientFactory(context.Background(), cfg, "default")
|
|
if err == nil {
|
|
t.Fatal("productionLLMClientFactory() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %q, want substring %q", err.Error(), test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProductionLLMClientFactoryReturnsScheduledClientAndManifestMetadata(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.LLMProfiles = map[string]config.LLMProfile{
|
|
"default": {
|
|
Provider: "openai-compatible",
|
|
BaseURL: "https://example.test",
|
|
Model: "model-a",
|
|
MaxConcurrency: 2,
|
|
},
|
|
}
|
|
|
|
client, metadata, err := productionLLMClientFactory(context.Background(), cfg, "default")
|
|
if err != nil {
|
|
t.Fatalf("productionLLMClientFactory() error = %v, want nil", err)
|
|
}
|
|
if client == nil {
|
|
t.Fatal("client = nil, want scheduled client")
|
|
}
|
|
if len(metadata) != 1 {
|
|
t.Fatalf("len(metadata) = %d, want 1", len(metadata))
|
|
}
|
|
if metadata[0].ID != "default" || metadata[0].Provider != "openai-compatible" || metadata[0].Model != "model-a" {
|
|
t.Fatalf("metadata = %#v, want profile-safe model metadata", metadata)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithProfiles("dnd-session"))
|
|
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 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 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 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: 1\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 mvpConfigYAML(pipelineID string, extractor string) string {
|
|
return `version: 1
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: ` + extractor + `
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithChunk(pipelineID string, chunker string, extractor string) string {
|
|
return `version: 1
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
chunk: ` + chunker + `
|
|
artifacts:
|
|
spells:
|
|
extract: ` + extractor + `
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string {
|
|
var b strings.Builder
|
|
b.WriteString("version: 1\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 mvpConfigYAMLWithProfiles(pipelineID string) string {
|
|
return `version: 1
|
|
llm_profiles:
|
|
default:
|
|
provider: openai-compatible
|
|
runtime:
|
|
provider: openai-compatible
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithDiagnostics(pipelineID, diagnosticsDir, retention string) string {
|
|
return `version: 1
|
|
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
|
|
}
|
|
|
|
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 (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
|
|
}
|
|
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 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 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) 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()
|
|
|
|
mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}})
|
|
mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}})
|
|
mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}})
|
|
mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}})
|
|
mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}})
|
|
mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}})
|
|
|
|
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
|
|
}
|
|
}
|