4039 lines
140 KiB
Go
4039 lines
140 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/shape"
|
|
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_refs"
|
|
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_relatedness"
|
|
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/validators/generic/always_accept"
|
|
alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/validators/generic/always_reject"
|
|
validjson "gitea.maximumdirect.net/eric/notarius/internal/validators/generic/valid_json"
|
|
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/validators/generic/valid_json_schema"
|
|
"gitea.maximumdirect.net/eric/scriptorium"
|
|
)
|
|
|
|
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 TestProductionCatalogIncludesProductionModulesValidatorsAndDefaults(t *testing.T) {
|
|
catalog, err := productionCatalog()
|
|
if err != nil {
|
|
t.Fatalf("productionCatalog() error = %v, want nil", err)
|
|
}
|
|
|
|
moduleTests := []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 moduleTests {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
validatorTests := []pipeline.ValidatorSpec{
|
|
alwaysaccept.Spec(),
|
|
alwaysreject.Spec(),
|
|
validjson.Spec(),
|
|
validjsonschema.Spec(),
|
|
spellshape.Spec(),
|
|
spellsourcerefs.Spec(),
|
|
spellrelatedness.Spec(),
|
|
}
|
|
for _, want := range validatorTests {
|
|
t.Run("validator "+want.Key, func(t *testing.T) {
|
|
got, ok := catalog.Validators.Spec(want.Key)
|
|
if !ok {
|
|
t.Fatalf("validator spec %q ok = false, want true", want.Key)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("validator spec = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
gotChain := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key)
|
|
wantChain := []pipeline.ModuleBinding{
|
|
pipeline.Binding(validjson.Key),
|
|
pipeline.Binding(validjsonschema.Key),
|
|
pipeline.Binding(spellshape.Key),
|
|
pipeline.Binding(spellsourcerefs.Key),
|
|
pipeline.Binding(spellrelatedness.Key),
|
|
}
|
|
if !reflect.DeepEqual(gotChain, wantChain) {
|
|
t.Fatalf("dnd spell default validator chain = %#v, want %#v", gotChain, wantChain)
|
|
}
|
|
if got := catalog.ValidatorChains.Validators(pipeline.StageChunk, generic.Key); len(got) != 0 {
|
|
t.Fatalf("generic chunker default validator chain = %#v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
|
|
registry, err := productionPromptAssets()
|
|
if err != nil {
|
|
t.Fatalf("productionPromptAssets() error = %v, want nil", err)
|
|
}
|
|
promptFS, err := registry.PromptFS()
|
|
if err != nil {
|
|
t.Fatalf("PromptFS() error = %v, want nil", err)
|
|
}
|
|
for _, name := range []string{
|
|
"dnd.scenes/dnd.scenes.yaml",
|
|
"dnd.scenes/task.md",
|
|
"dnd.scenes/instructions.md",
|
|
"dnd.scenes/sharedassets/common-dnd-system.md",
|
|
"dnd.scenes/sharedassets/common-dnd-transcript.md",
|
|
"dnd.scenes/sharedassets/common-dnd-references.md",
|
|
"dnd.spells/dnd.spells.yaml",
|
|
"dnd.spells/task.md",
|
|
"dnd.spells/instructions.md",
|
|
"dnd.spells/sharedassets/common-dnd-system.md",
|
|
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
|
"dnd.spells/sharedassets/common-dnd-references.md",
|
|
} {
|
|
if _, err := promptFS.Open(name); err != nil {
|
|
t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err)
|
|
}
|
|
}
|
|
for _, name := range []string{
|
|
"common-dnd-system.md",
|
|
"common-dnd-transcript.md",
|
|
"common-dnd-references.md",
|
|
} {
|
|
if _, err := promptFS.Open(name); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("PromptFS().Open(%q) error = %v, want not exist", name, err)
|
|
}
|
|
}
|
|
|
|
options, err := registry.ScriptoriumOptions()
|
|
if err != nil {
|
|
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
|
|
}
|
|
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
|
|
ID: "production-test-profile",
|
|
Endpoint: "http://127.0.0.1:1/v1",
|
|
Model: "production-test-model",
|
|
})))
|
|
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
|
|
if err != nil {
|
|
t.Fatalf("NewEngine() error = %v, want nil", err)
|
|
}
|
|
|
|
sceneTranscript := `{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`
|
|
scenesPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
|
PromptID: scenes.PromptID,
|
|
PromptVersion: scenes.ResponseSchemaVersion,
|
|
ProfileID: "production-test-profile",
|
|
Inputs: map[string]scriptorium.ArtifactRef{
|
|
"transcript": scriptorium.InlineWithURI("file:///session.json", sceneTranscript),
|
|
"players": scriptorium.Inline("Alice: Aria"),
|
|
"party": scriptorium.Inline("Aria: cleric"),
|
|
"glossary": scriptorium.Inline("Brightmantle: temple"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("scene Prepare() error = %v, want nil", err)
|
|
}
|
|
if got := len(scenesPrepared.Messages); got != 5 {
|
|
t.Fatalf("scene message count = %d, want 5", got)
|
|
}
|
|
if !strings.Contains(scenesPrepared.Messages[1].Content, sceneTranscript) {
|
|
t.Fatalf("scene transcript message did not include source input")
|
|
}
|
|
for _, want := range []string{"Alice: Aria", "Aria: cleric", "Brightmantle: temple"} {
|
|
if !strings.Contains(scenesPrepared.Messages[2].Content, want) {
|
|
t.Fatalf("scene reference message missing %q", want)
|
|
}
|
|
}
|
|
|
|
spellTranscript := `{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`
|
|
spellsPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
|
PromptID: spells.PromptID,
|
|
PromptVersion: spells.SchemaVersion,
|
|
ProfileID: "production-test-profile",
|
|
Inputs: map[string]scriptorium.ArtifactRef{
|
|
"transcript": scriptorium.InlineWithURI("file:///session.json", spellTranscript),
|
|
"players": scriptorium.Inline("Dana: Mira"),
|
|
"party": scriptorium.Inline("Mira: wizard"),
|
|
"glossary": scriptorium.Inline("Shield: abjuration"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("spell Prepare() error = %v, want nil", err)
|
|
}
|
|
if got := len(spellsPrepared.Messages); got != 5 {
|
|
t.Fatalf("spell message count = %d, want 5", got)
|
|
}
|
|
if !strings.Contains(spellsPrepared.Messages[1].Content, spellTranscript) {
|
|
t.Fatalf("spell transcript message did not include source input")
|
|
}
|
|
for _, want := range []string{"Dana: Mira", "Mira: wizard", "Shield: abjuration"} {
|
|
if !strings.Contains(spellsPrepared.Messages[2].Content, want) {
|
|
t.Fatalf("spell reference message missing %q", 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 TestRunConfigValidateRejectsUnknownProductionValidator(t *testing.T) {
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithExtractValidators("dnd-session", "\n - missing/validator\n"))
|
|
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)
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "unknown validator") || !strings.Contains(got, "missing/validator") {
|
|
t.Fatalf("stderr = %q, want unknown validator", got)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateRejectsLLMProfileForDeterministicProductionValidator(t *testing.T) {
|
|
configPath := writeTestConfig(t, `version: 2
|
|
pipelines:
|
|
dnd-session:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract:
|
|
module: dnd/spells
|
|
validators:
|
|
- module: generic/valid_json
|
|
llm_profile: review
|
|
`)
|
|
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{"llm_profile", "deterministic", validjson.Key} {
|
|
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", "outputs=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(), "outputs=1") {
|
|
t.Fatalf("stdout = %q, want output 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 TestRunPipelineDefaultDNDSpellValidatorsRejectInvalidSourceRefs(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(), "outputs=0") || !strings.Contains(stdout.String(), "rejected=1") {
|
|
t.Fatalf("stdout = %q, want rejected output count", stdout.String())
|
|
}
|
|
|
|
var manifest artifacts.RunManifest
|
|
readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest)
|
|
if manifest.ValidationStatus != "rejected" {
|
|
t.Fatalf("validation status = %q, want rejected", manifest.ValidationStatus)
|
|
}
|
|
if len(manifest.RejectedOutputs) != 1 {
|
|
t.Fatalf("rejected outputs = %#v, want one rejection", manifest.RejectedOutputs)
|
|
}
|
|
rejection := manifest.RejectedOutputs[0]
|
|
if rejection.ValidatorName != spellsourcerefs.Key || rejection.ReasonCode != spellsourcerefs.ReasonCode {
|
|
t.Fatalf("rejection = %#v, want source reference validator rejection", rejection)
|
|
}
|
|
gotChain := manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)
|
|
wantKeys := []string{validjson.Key, validjsonschema.Key, spellshape.Key, spellsourcerefs.Key, spellrelatedness.Key}
|
|
if got := manifestValidatorKeys(gotChain); !reflect.DeepEqual(got, wantKeys) {
|
|
t.Fatalf("validator chain keys = %#v, want %#v", got, wantKeys)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineExplicitEmptyValidatorOverrideDisablesDNDSpellDefaults(t *testing.T) {
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithExtractValidators("dnd-session", " []\n"))
|
|
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(), "outputs=1") || !strings.Contains(stdout.String(), "rejected=0") {
|
|
t.Fatalf("stdout = %q, want accepted output count", stdout.String())
|
|
}
|
|
var manifest artifacts.RunManifest
|
|
readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest)
|
|
gotChain := manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)
|
|
if len(gotChain.Validators) != 0 {
|
|
t.Fatalf("validator chain = %#v, want explicit empty chain", gotChain)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineExplicitValidatorOverrideReplacesDNDSpellDefaults(t *testing.T) {
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithExtractValidators("dnd-session", "\n - "+alwaysaccept.Key+"\n"))
|
|
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(), "outputs=1") || !strings.Contains(stdout.String(), "rejected=0") {
|
|
t.Fatalf("stdout = %q, want accepted output count", stdout.String())
|
|
}
|
|
var manifest artifacts.RunManifest
|
|
readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest)
|
|
gotChain := manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)
|
|
if got := manifestValidatorKeys(gotChain); !reflect.DeepEqual(got, []string{alwaysaccept.Key}) {
|
|
t.Fatalf("validator chain keys = %#v, want explicit override", got)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineConfiguredValidatorOrderIsPreserved(t *testing.T) {
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithExtractValidators("dnd-session", "\n - "+alwaysaccept.Key+"\n - "+validjson.Key+"\n"))
|
|
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)
|
|
gotChain := manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)
|
|
wantKeys := []string{alwaysaccept.Key, validjson.Key}
|
|
if got := manifestValidatorKeys(gotChain); !reflect.DeepEqual(got, wantKeys) {
|
|
t.Fatalf("validator chain keys = %#v, want %#v", got, wantKeys)
|
|
}
|
|
}
|
|
|
|
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 TestRunConfigValidateChecksExplicitLLMValidatorProfileIDs(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
|
|
validators:
|
|
- module: fake/llm-validator
|
|
llm_profile: missing
|
|
`)
|
|
catalog := fakeCatalog(t)
|
|
mustRegisterValidator(t, catalog.Validators, pipeline.ValidatorSpec{
|
|
Key: "fake/llm-validator",
|
|
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
|
})
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{
|
|
Catalog: catalog,
|
|
})
|
|
|
|
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 validator Scriptorium profile", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateIncludesMergeAndIgnoresNonLLMStageScriptoriumProfiles(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:
|
|
module: fake/input
|
|
llm_profile: missing-input
|
|
output:
|
|
module: json
|
|
llm_profile: missing-output
|
|
artifacts:
|
|
events:
|
|
extract: fake/extract
|
|
merge:
|
|
module: appendorder
|
|
llm_profile: missing-merge
|
|
`)
|
|
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 failure for missing merge profile", code)
|
|
}
|
|
if !strings.Contains(stderr.String(), "Scriptorium profile") || !strings.Contains(stderr.String(), "missing-merge") {
|
|
t.Fatalf("stderr = %q, want missing merge profile error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestEffectiveLLMProfileIDsUsesLLMCapableStagesOnly(t *testing.T) {
|
|
resolved := pipeline.ResolvedPipeline{
|
|
Input: pipeline.ModuleBinding{LLMProfile: "input-profile"},
|
|
Chunk: pipeline.ModuleBinding{LLMProfile: "chunk-profile"},
|
|
Output: pipeline.ModuleBinding{LLMProfile: "output-profile"},
|
|
ArtifactLanes: []pipeline.ResolvedArtifactLane{
|
|
{
|
|
Extract: pipeline.ModuleBinding{LLMProfile: "extract-profile"},
|
|
Merge: pipeline.ModuleBinding{LLMProfile: "merge-profile"},
|
|
Normalize: pipeline.ModuleBinding{LLMProfile: "normalize-profile"},
|
|
},
|
|
},
|
|
ValidatorChains: []pipeline.ResolvedValidatorChain{
|
|
{
|
|
Stage: pipeline.StageExtract,
|
|
LaneID: "events",
|
|
ModuleKey: "extract",
|
|
Validators: []pipeline.ResolvedValidator{
|
|
{
|
|
Binding: pipeline.ModuleBinding{Module: "deterministic-validator", LLMProfile: "ignored-validator-profile"},
|
|
ExecutionClass: contracts.ExecutionClassDeterministic,
|
|
},
|
|
{
|
|
Binding: pipeline.ModuleBinding{Module: "llm-validator", LLMProfile: "validator-profile"},
|
|
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
got := effectiveLLMProfileIDs(resolved)
|
|
want := []string{"chunk-profile", "extract-profile", "merge-profile", "normalize-profile", "validator-profile"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("effectiveLLMProfileIDs() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
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 TestRunPipelineReferenceFlagBindsExplicitMergeSlot(t *testing.T) {
|
|
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
|
inputPath := filepath.Join(t.TempDir(), "missing.json")
|
|
referencePath := writeFile(t, "merge.md", "Merge notes\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.merge.notes=" + referencePath,
|
|
}, &stdout, &stderr, Options{
|
|
Catalog: fakeCatalog(t, pipeline.ModuleSpec{
|
|
Key: "appendorder",
|
|
Stage: pipeline.StageMerge,
|
|
Requires: []string{"artifact"},
|
|
Provides: []string{"merged"},
|
|
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].MergeReferences.Bindings
|
|
want := []pipeline.ReferenceBinding{
|
|
{LaneID: "events", SlotName: "notes", Source: referencePath, BindingSource: contracts.ReferenceBindingSourceCLI},
|
|
}
|
|
if !reflect.DeepEqual(refs, want) {
|
|
t.Fatalf("merge references = %#v, want %#v", refs, want)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineReferenceFlagBindsUnambiguousMergeSlot(t *testing.T) {
|
|
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
|
inputPath := filepath.Join(t.TempDir(), "missing.json")
|
|
referencePath := writeFile(t, "merge.md", "Merge notes\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", "merge.notes=" + referencePath,
|
|
}, &stdout, &stderr, Options{
|
|
Catalog: fakeCatalog(t, pipeline.ModuleSpec{
|
|
Key: "appendorder",
|
|
Stage: pipeline.StageMerge,
|
|
Requires: []string{"artifact"},
|
|
Provides: []string{"merged"},
|
|
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].MergeReferences.Bindings
|
|
if len(refs) != 1 || refs[0].Source != referencePath || refs[0].LaneID != "events" {
|
|
t.Fatalf("merge references = %#v, want unambiguous merge binding", refs)
|
|
}
|
|
}
|
|
|
|
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, lane.merge.slot, or lane.normalize.slot"},
|
|
{name: "too many selector parts", args: []string{"--reference", "a.b.c.d=./roster.yml"}, want: "slot, chunk.slot, merge.slot, lane.slot, lane.extract.slot, lane.merge.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",
|
|
"merge_notes": "./config-merge.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.merge.merge_notes",
|
|
"--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: "appendorder",
|
|
Stage: pipeline.StageMerge,
|
|
Requires: []string{"artifact"},
|
|
Provides: []string{"merged"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "merge_notes"},
|
|
},
|
|
},
|
|
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].MergeReferences.Bindings; len(refs) != 0 {
|
|
t.Fatalf("merge 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",
|
|
"lanes/spells.json",
|
|
"manifest.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, `"output_count": 1`) || !strings.Contains(report, `"validation_status": "approved"`) || !strings.Contains(report, outputDir) {
|
|
t.Fatalf("unexpected run report: %s", report)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineWritesWorkspaceDiagnosticsArtifactsOnSuccess(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnostics("dnd-session", workspaceDir, "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, filepath.Join(workspaceDir, "diagnostics"))
|
|
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 workspace diagnostics artifact %q: %v", name, err)
|
|
}
|
|
}
|
|
assertPathNotExist(t, filepath.Join(workspaceDir, "checkpoints"))
|
|
assertPathNotExist(t, filepath.Join(workspaceDir, "debug"))
|
|
}
|
|
|
|
func TestRunPipelineWritesCheckpointsWhenWorkspaceResumeEnabled(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeEnabled("dnd-session", workspaceDir, "always"))
|
|
inputPath := writeSeriatimInput(t)
|
|
client := newFakeRunLLMClient(false)
|
|
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(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 ordinary run to execute despite checkpoint writing", client.calls)
|
|
}
|
|
checkpointDir := onlyCheckpointIdentityDir(t, workspaceDir)
|
|
for _, name := range []string{
|
|
"source/manifest.json",
|
|
"source/source-document.json",
|
|
"chunk/manifest.json",
|
|
"chunk/chunks.json",
|
|
"extract/spells/manifest.json",
|
|
"extract/spells/outputs.json",
|
|
"merge/spells/manifest.json",
|
|
"merge/spells/output.json",
|
|
"normalize/spells/manifest.json",
|
|
"normalize/spells/output.json",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(checkpointDir, name)); err != nil {
|
|
t.Fatalf("expected checkpoint artifact %q: %v", name, err)
|
|
}
|
|
}
|
|
assertPathNotExist(t, filepath.Join(workspaceDir, "debug"))
|
|
}
|
|
|
|
func TestRunPipelineWritesDebugWhenWorkspaceDebugEnabled(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDebugEnabled("dnd-session", workspaceDir, "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())
|
|
}
|
|
debugDir := onlyChildDir(t, filepath.Join(workspaceDir, "debug"))
|
|
for _, name := range []string{
|
|
"run.json",
|
|
"source/input.json",
|
|
"source/output.json",
|
|
"chunk/input.json",
|
|
"chunk/output.json",
|
|
"extract/spells/input.json",
|
|
"extract/spells/chunk-000001/attempt-01.json",
|
|
"extract/spells/chunk-000001/attempt-01/prompt-0001.json",
|
|
"extract/spells/chunk-000001/attempt-01/response-0001.json",
|
|
"extract/spells/chunk-000001/attempt-01/response-content-0001.json",
|
|
"extract/spells/output.json",
|
|
"merge/spells/input.json",
|
|
"merge/spells/output.json",
|
|
"normalize/spells/input.json",
|
|
"normalize/spells/output.json",
|
|
"output/input.json",
|
|
"output/output.json",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(debugDir, name)); err != nil {
|
|
t.Fatalf("expected debug artifact %q: %v", name, err)
|
|
}
|
|
}
|
|
attemptDebug := string(readFile(t, filepath.Join(debugDir, "extract/spells/chunk-000001/attempt-01.json")))
|
|
if !strings.Contains(attemptDebug, `"llm_calls"`) || !strings.Contains(attemptDebug, `"prompt_path"`) || !strings.Contains(attemptDebug, `"response_path"`) || !strings.Contains(attemptDebug, `"response_content_path"`) {
|
|
t.Fatalf("extract attempt debug = %s, want prompt/response llm_calls", attemptDebug)
|
|
}
|
|
responseDebug := string(readFile(t, filepath.Join(debugDir, "extract/spells/chunk-000001/attempt-01/response-0001.json")))
|
|
if !strings.Contains(responseDebug, `"content_path"`) || strings.Contains(responseDebug, `spell_casts`) {
|
|
t.Fatalf("response debug = %s, want metadata with content path and no inline response", responseDebug)
|
|
}
|
|
responseContent := string(readFile(t, filepath.Join(debugDir, "extract/spells/chunk-000001/attempt-01/response-content-0001.json")))
|
|
if !strings.Contains(responseContent, `"spell_casts"`) || !strings.Contains(responseContent, "\n ") {
|
|
t.Fatalf("response content = %s, want pretty JSON response body", responseContent)
|
|
}
|
|
assertPathNotExist(t, filepath.Join(debugDir, "llm/call-0001.json"))
|
|
assertPathNotExist(t, filepath.Join(debugDir, "extract/spells/chunk-000001-attempt-01/llm-call-0001.json"))
|
|
if _, err := os.Stat(filepath.Join(debugDir, "extract/spells/chunk-000001-attempt-01.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("old extract attempt path still exists: %v", err)
|
|
}
|
|
assertPathNotExist(t, filepath.Join(workspaceDir, "checkpoints"))
|
|
}
|
|
|
|
func TestRunPipelineDebugAndResumeCanBeEnabledIndependently(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnosticsAndStateEnabled("dnd-session", workspaceDir, "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("seed RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
code = RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--resume"}, &stdout, &stderr, Options{
|
|
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("resume RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
if entries := childDirs(t, filepath.Join(workspaceDir, "checkpoints")); len(entries) != 1 {
|
|
t.Fatalf("workspace checkpoint pipeline dirs = %v, want one", entries)
|
|
}
|
|
if entries := childDirs(t, filepath.Join(workspaceDir, "debug")); len(entries) != 2 {
|
|
t.Fatalf("workspace debug run dirs = %v, want two", entries)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineDebugRedactsObviousSecrets(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDebugEnabled("dnd-session", workspaceDir, "always"))
|
|
inputPath := writeSeriatimInput(t)
|
|
client := newFakeRunLLMClient(false)
|
|
client.payload = map[string]any{
|
|
"spell_casts": []map[string]any{
|
|
{
|
|
"caster": "Aria",
|
|
"spell": "sk-secretvalue",
|
|
"effect": "Bearer secretvalue",
|
|
"narrative_description": "Aria casts a spell.",
|
|
"source_refs": []map[string]any{
|
|
{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 1},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
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(client, nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
assertDebugTreeDoesNotContain(t, onlyChildDir(t, filepath.Join(workspaceDir, "debug")), "sk-secretvalue", "Bearer secretvalue")
|
|
}
|
|
|
|
func TestWorkspaceStateRootsDoNotOverlap(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnosticsAndStateEnabled("dnd-session", workspaceDir, "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())
|
|
}
|
|
assertDistinctRoots(t, filepath.Join(workspaceDir, "diagnostics"), filepath.Join(workspaceDir, "checkpoints"), filepath.Join(workspaceDir, "debug"))
|
|
}
|
|
|
|
func TestRunPipelineResumeRequiresWorkspaceResumeEnabled(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnostics("dnd-session", workspaceDir, "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, "--resume"}, &stdout, &stderr, Options{
|
|
LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil),
|
|
})
|
|
|
|
if code != 1 {
|
|
t.Fatalf("RunWithOptions() code = %d, want 1", code)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--resume requires workspace.resume.enabled: true") {
|
|
t.Fatalf("stderr = %q, want resume configuration error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineResumeReusesWorkspaceCheckpoints(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeEnabled("dnd-session", workspaceDir, "always"))
|
|
inputPath := writeSeriatimInput(t)
|
|
firstClient := newFakeRunLLMClient(false)
|
|
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(firstClient, nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("first RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
if firstClient.calls != 1 {
|
|
t.Fatalf("first LLM calls = %d, want checkpoint seed run to execute", firstClient.calls)
|
|
}
|
|
|
|
secondClient := newFakeRunLLMClient(false)
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = RunWithOptions([]string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", outputDir, "--resume"}, &stdout, &stderr, Options{
|
|
LLMClientFactory: fakeLLMFactory(secondClient, nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("resume RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
if secondClient.calls != 0 {
|
|
t.Fatalf("resume LLM calls = %d, want checkpoint reuse", secondClient.calls)
|
|
}
|
|
runDirs := childDirs(t, filepath.Join(workspaceDir, "diagnostics"))
|
|
if len(runDirs) != 2 {
|
|
t.Fatalf("diagnostics run dirs = %v, want fresh diagnostics for each invocation", runDirs)
|
|
}
|
|
if !anyDiagnosticsFileContains(t, runDirs, diagnostics.ArtifactCheckpointEvents, `"action": "reused"`) {
|
|
t.Fatalf("checkpoint event diagnostics under %v did not record reuse", runDirs)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineResumeInvalidatesWhenInvocationIdentityChanges(t *testing.T) {
|
|
t.Run("input", func(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeEnabled("dnd-session", workspaceDir, "always"))
|
|
seedInput := writeSeriatimInput(t)
|
|
changedInput := writeFile(t, "source-changed.json", `{
|
|
"metadata": {"id": "session-alpha"},
|
|
"segments": [{"id": 1, "start": 0, "end": 1, "speaker": "Aria", "text": "Aria casts Shield."}]
|
|
}`)
|
|
seedWorkspaceCheckpoint(t, configPath, seedInput, nil)
|
|
client := runResumeWithClient(t, configPath, changedInput, nil)
|
|
if client.calls == 0 {
|
|
t.Fatal("resume LLM calls = 0, want execution after input identity change")
|
|
}
|
|
})
|
|
|
|
t.Run("pipeline digest", func(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
seedConfig := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeEnabled("dnd-session", workspaceDir, "always"))
|
|
changedConfig := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeAndChunkOptions("dnd-session", workspaceDir, "always"))
|
|
inputPath := writeSeriatimInput(t)
|
|
seedWorkspaceCheckpoint(t, seedConfig, inputPath, nil)
|
|
client := runResumeWithClient(t, changedConfig, inputPath, nil)
|
|
if client.calls == 0 {
|
|
t.Fatal("resume LLM calls = 0, want execution after pipeline digest change")
|
|
}
|
|
})
|
|
|
|
t.Run("selected lanes", func(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeLanes("dnd-session", workspaceDir, "always", "spells", "items"))
|
|
inputPath := writeSeriatimInput(t)
|
|
seedWorkspaceCheckpoint(t, configPath, inputPath, nil)
|
|
client := runResumeWithClient(t, configPath, inputPath, []string{"--only", "spells"})
|
|
if client.calls == 0 {
|
|
t.Fatal("resume LLM calls = 0, want execution after selected lane change")
|
|
}
|
|
})
|
|
|
|
t.Run("references", func(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeEnabled("dnd-session", workspaceDir, "always"))
|
|
inputPath := writeSeriatimInput(t)
|
|
referencePath := writeFile(t, "players.md", "Aria is a cleric.\n")
|
|
seedWorkspaceCheckpoint(t, configPath, inputPath, []string{"--reference", "players=" + referencePath})
|
|
if err := os.WriteFile(referencePath, []byte("Aria is a wizard.\n"), 0o644); err != nil {
|
|
t.Fatalf("update reference: %v", err)
|
|
}
|
|
client := runResumeWithClient(t, configPath, inputPath, []string{"--reference", "players=" + referencePath})
|
|
if client.calls == 0 {
|
|
t.Fatal("resume LLM calls = 0, want execution after reference digest change")
|
|
}
|
|
})
|
|
|
|
t.Run("llm profile override", func(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
profilePath := writeScriptoriumProfileFile(t, "runtime", "http://profile.test/v1", "test-model")
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceResumeAndProfileFile("dnd-session", workspaceDir, "always", profilePath))
|
|
inputPath := writeSeriatimInput(t)
|
|
seedWorkspaceCheckpoint(t, configPath, inputPath, nil)
|
|
client := runResumeWithClient(t, configPath, inputPath, []string{"--llm-profile", "runtime"})
|
|
if client.calls == 0 {
|
|
t.Fatal("resume LLM calls = 0, want execution after LLM profile override change")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunPipelineSkipsDiagnosticsWhenWorkspaceDiagnosticsDisabled(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnosticsDisabled("dnd-session", workspaceDir))
|
|
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 _, err := os.Stat(filepath.Join(workspaceDir, "diagnostics")); !os.IsNotExist(err) {
|
|
t.Fatalf("workspace diagnostics dir stat err = %v, want not exist", err)
|
|
}
|
|
if entries := childDirs(t, outputDir); len(entries) != 1 {
|
|
t.Fatalf("output run dirs = %v, want one run dir", entries)
|
|
}
|
|
}
|
|
|
|
func TestRunPipelineLegacyDiagnosticsConfigStillWritesDiagnostics(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)
|
|
if _, err := os.Stat(filepath.Join(runDir, diagnostics.ArtifactRunReport)); err != nil {
|
|
t.Fatalf("expected legacy diagnostics run report: %v", err)
|
|
}
|
|
}
|
|
|
|
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 TestRunPipelineDiagnosticsDirFlagOverridesWorkspaceDiagnosticsOnly(t *testing.T) {
|
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
|
overrideDiagnosticsDir := t.TempDir()
|
|
outputDir := t.TempDir()
|
|
configPath := writeTestConfig(t, mvpConfigYAMLWithWorkspaceDiagnosticsAndStateEnabled("dnd-session", workspaceDir, "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, overrideDiagnosticsDir); len(entries) != 1 {
|
|
t.Fatalf("override diagnostics dir entries = %v, want one run dir", entries)
|
|
}
|
|
assertPathNotExist(t, filepath.Join(workspaceDir, "diagnostics"))
|
|
if entries := childDirs(t, filepath.Join(workspaceDir, "checkpoints")); len(entries) != 1 {
|
|
t.Fatalf("workspace checkpoint pipeline dirs = %v, want one", entries)
|
|
}
|
|
if entries := childDirs(t, filepath.Join(workspaceDir, "debug")); len(entries) != 1 {
|
|
t.Fatalf("workspace debug run dirs = %v, want one", entries)
|
|
}
|
|
}
|
|
|
|
func TestExampleFixtureConfigValidateAndPipelinesList(t *testing.T) {
|
|
for _, path := range []string{
|
|
"examples/dnd-spells.config.yml",
|
|
"examples/dnd-spells-production.config.yml",
|
|
} {
|
|
path := path
|
|
t.Run("validate "+filepath.Base(path), func(t *testing.T) {
|
|
configPath := fixturePath(t, path)
|
|
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) {
|
|
configPath := fixturePath(t, "examples/dnd-spells.config.yml")
|
|
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 spellOutput struct {
|
|
SpellCasts []struct {
|
|
Caster string `json:"caster"`
|
|
Spell string `json:"spell"`
|
|
Effect string `json:"effect"`
|
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
|
} `json:"spell_casts"`
|
|
}
|
|
readJSONFile(t, filepath.Join(runOutputDir, "lanes", "spells.json"), &spellOutput)
|
|
if len(spellOutput.SpellCasts) != 1 {
|
|
t.Fatalf("spell output = %#v, want one spell cast", spellOutput)
|
|
}
|
|
payload := spellOutput.SpellCasts[0]
|
|
if payload.Caster != "Aria" || payload.Spell != "Cure Wounds" || payload.Effect == "" {
|
|
t.Fatalf("payload = %#v, want deterministic spell output", payload)
|
|
}
|
|
if len(payload.SourceRefs) != 1 {
|
|
t.Fatalf("source refs = %#v, want one source ref", payload.SourceRefs)
|
|
}
|
|
ref := payload.SourceRefs[0]
|
|
if ref.SourceID != "session-alpha" || ref.StartUnitID != 1 || ref.EndUnitID != 1 {
|
|
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 rejected by validation",
|
|
args: []string{"run", "dnd-session", "--config", configPath, "--input", inputPath},
|
|
factory: fakeLLMFactory(newMalformedRunLLMClient(), nil),
|
|
wantCode: 0,
|
|
wantOutputStatus: "rejected",
|
|
},
|
|
{
|
|
name: "invalid source reference rejected by validation",
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 mvpConfigYAMLWithExtractValidators(pipelineID string, validators string) string {
|
|
return `version: 2
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract:
|
|
module: dnd/spells
|
|
validators:` + validators
|
|
}
|
|
|
|
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 mvpConfigYAMLWithWorkspaceDiagnostics(pipelineID, workspaceDir, retention string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceDiagnosticsAndStateEnabled(pipelineID, workspaceDir, retention string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
resume:
|
|
enabled: true
|
|
debug:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceResumeEnabled(pipelineID, workspaceDir, retention string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
resume:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceDebugEnabled(pipelineID, workspaceDir, retention string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
debug:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceResumeAndChunker(pipelineID, workspaceDir, retention, chunker string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
resume:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
chunk: ` + chunker + `
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceResumeAndChunkOptions(pipelineID, workspaceDir, retention string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
resume:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
chunk:
|
|
module: generic
|
|
options:
|
|
max_units: 10
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceResumeAndProfileFile(pipelineID, workspaceDir, retention, profileFile string) string {
|
|
return `version: 2
|
|
scriptorium:
|
|
profile_file: ` + profileFile + `
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: true
|
|
retention: ` + retention + `
|
|
resume:
|
|
enabled: true
|
|
pipelines:
|
|
` + pipelineID + `:
|
|
input: seriatim
|
|
artifacts:
|
|
spells:
|
|
extract: dnd/spells
|
|
`
|
|
}
|
|
|
|
func mvpConfigYAMLWithWorkspaceResumeLanes(pipelineID, workspaceDir, retention string, laneIDs ...string) string {
|
|
var b strings.Builder
|
|
b.WriteString("version: 2\n")
|
|
b.WriteString("workspace:\n")
|
|
b.WriteString(" directory: " + workspaceDir + "\n")
|
|
b.WriteString(" diagnostics:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" retention: " + retention + "\n")
|
|
b.WriteString(" resume:\n")
|
|
b.WriteString(" enabled: true\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 mvpConfigYAMLWithWorkspaceDiagnosticsDisabled(pipelineID, workspaceDir string) string {
|
|
return `version: 2
|
|
workspace:
|
|
directory: ` + workspaceDir + `
|
|
diagnostics:
|
|
enabled: false
|
|
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": 1,
|
|
"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": 1,
|
|
"end_unit_id": 2,
|
|
"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 fakeRunStructuredResponse(req, encoded), nil
|
|
}
|
|
startUnitID := 1
|
|
if client.invalidSourceRef {
|
|
startUnitID = 999
|
|
}
|
|
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]any{
|
|
{
|
|
"source_id": "session-alpha",
|
|
"start_unit_id": startUnitID,
|
|
"end_unit_id": 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
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 fakeRunStructuredResponse(req, encoded), nil
|
|
}
|
|
|
|
func fakeRunStructuredResponse(req contracts.StructuredCompletionRequest, content []byte) contracts.StructuredCompletionResponse {
|
|
profileID := req.ProfileID
|
|
if profileID == "" {
|
|
profileID = "fake-profile"
|
|
}
|
|
return contracts.StructuredCompletionResponse{
|
|
Content: content,
|
|
Model: "fake-model",
|
|
ProfileID: profileID,
|
|
Debug: &contracts.LLMDebugMaterial{
|
|
Prompt: &contracts.LLMDebugPrompt{
|
|
PromptID: req.PromptID,
|
|
PromptVersion: req.PromptVersion,
|
|
SelectedProfileID: profileID,
|
|
SessionID: req.SessionID,
|
|
Messages: []contracts.LLMDebugMessage{
|
|
{Role: "user", Content: "fake rendered prompt for " + req.PromptID},
|
|
},
|
|
},
|
|
Response: &contracts.LLMDebugResponse{
|
|
Content: string(content),
|
|
PromptID: req.PromptID,
|
|
PromptVersion: req.PromptVersion,
|
|
SelectedProfileID: profileID,
|
|
ModelName: "fake-model",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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: 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, StartUnitID: 1, EndUnitID: 1, Content: []byte(`{"units":[1]}`), MediaType: "application/json", Units: req.Source.Units},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type fakeRunExtractor struct{}
|
|
|
|
func (fakeRunExtractor) Key() string {
|
|
return "fake/extract"
|
|
}
|
|
|
|
func (fakeRunExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
|
return []contracts.ReferenceSlot{{Name: "roster"}}
|
|
}
|
|
|
|
func (fakeRunExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
|
return contracts.ExtractionResult{
|
|
Output: contracts.ExtractOutput{
|
|
Schema: contracts.ResponseSchema{ID: "fake.artifact", Name: "fake_artifact", Version: "v1"},
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"value":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type fakeRunMerger struct{}
|
|
|
|
func (fakeRunMerger) Key() string {
|
|
return "appendorder"
|
|
}
|
|
|
|
func (fakeRunMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) {
|
|
output := contracts.MergeOutput{
|
|
LaneID: req.LaneID,
|
|
SourceID: req.Source.ID,
|
|
Schema: contracts.ResponseSchema{ID: "fake.artifact", Name: "fake_artifact", Version: "v1"},
|
|
Payload: contracts.RawPayload{
|
|
Content: []byte(`{"merged":true}`),
|
|
MediaType: "application/json",
|
|
},
|
|
}
|
|
if len(req.ExtractOutputs) > 0 {
|
|
output.Schema = req.ExtractOutputs[0].Schema
|
|
output.Payload = req.ExtractOutputs[0].Payload
|
|
}
|
|
return contracts.MergeResult{Output: output}, 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{
|
|
Output: contracts.NormalizeOutput{
|
|
LaneID: req.LaneID,
|
|
SourceID: req.MergeOutput.SourceID,
|
|
Schema: req.MergeOutput.Schema,
|
|
Payload: req.MergeOutput.Payload,
|
|
},
|
|
}, 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 onlyCheckpointIdentityDir(t *testing.T, workspaceDir string) string {
|
|
t.Helper()
|
|
pipelineDir := onlyChildDir(t, filepath.Join(workspaceDir, "checkpoints"))
|
|
inputDir := onlyChildDir(t, pipelineDir)
|
|
pipelineDigestDir := onlyChildDir(t, inputDir)
|
|
return onlyChildDir(t, pipelineDigestDir)
|
|
}
|
|
|
|
func anyDiagnosticsFileContains(t *testing.T, runDirs []string, name string, want string) bool {
|
|
t.Helper()
|
|
for _, runDir := range runDirs {
|
|
data, err := os.ReadFile(filepath.Join(runDir, name))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
t.Fatalf("read diagnostics artifact %q under %q: %v", name, runDir, err)
|
|
}
|
|
if strings.Contains(string(data), want) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func assertDistinctRoots(t *testing.T, roots ...string) {
|
|
t.Helper()
|
|
for i, first := range roots {
|
|
for _, second := range roots[i+1:] {
|
|
firstAbs, err := filepath.Abs(first)
|
|
if err != nil {
|
|
t.Fatalf("resolve %q: %v", first, err)
|
|
}
|
|
secondAbs, err := filepath.Abs(second)
|
|
if err != nil {
|
|
t.Fatalf("resolve %q: %v", second, err)
|
|
}
|
|
if firstAbs == secondAbs {
|
|
t.Fatalf("workspace roots overlap exactly: %q", firstAbs)
|
|
}
|
|
firstRel, err := filepath.Rel(firstAbs, secondAbs)
|
|
if err != nil {
|
|
t.Fatalf("rel %q %q: %v", firstAbs, secondAbs, err)
|
|
}
|
|
secondRel, err := filepath.Rel(secondAbs, firstAbs)
|
|
if err != nil {
|
|
t.Fatalf("rel %q %q: %v", secondAbs, firstAbs, err)
|
|
}
|
|
if !strings.HasPrefix(firstRel, ".."+string(filepath.Separator)) && firstRel != ".." {
|
|
t.Fatalf("workspace root %q contains %q", firstAbs, secondAbs)
|
|
}
|
|
if !strings.HasPrefix(secondRel, ".."+string(filepath.Separator)) && secondRel != ".." {
|
|
t.Fatalf("workspace root %q contains %q", secondAbs, firstAbs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var debugBase64FieldPattern = regexp.MustCompile(`"content_base64"\s*:\s*"([^"]*)"`)
|
|
var debugRawLLMResponseContentPattern = regexp.MustCompile(`"content"\s*:\s*"(?:\\.|[^"\\])*"`)
|
|
|
|
func assertDebugTreeDoesNotContain(t *testing.T, root string, forbidden ...string) {
|
|
t.Helper()
|
|
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(filepath.Base(path), "response-content-") {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text := debugRawLLMResponseContentPattern.ReplaceAllString(string(data), `"content":"[RAW_LLM_RESPONSE]"`)
|
|
for _, value := range forbidden {
|
|
if strings.Contains(text, value) {
|
|
t.Fatalf("debug artifact %q contains forbidden value %q", path, value)
|
|
}
|
|
}
|
|
for _, match := range debugBase64FieldPattern.FindAllStringSubmatch(text, -1) {
|
|
decoded, err := base64.StdEncoding.DecodeString(match[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
decodedText := string(decoded)
|
|
for _, value := range forbidden {
|
|
if strings.Contains(decodedText, value) {
|
|
t.Fatalf("debug artifact %q decoded content contains forbidden value %q", path, value)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("walk debug tree %q: %v", root, err)
|
|
}
|
|
}
|
|
|
|
func seedWorkspaceCheckpoint(t *testing.T, configPath string, inputPath string, extraArgs []string) {
|
|
t.Helper()
|
|
client := newFakeRunLLMClient(false)
|
|
args := []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", t.TempDir()}
|
|
args = append(args, extraArgs...)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := RunWithOptions(args, &stdout, &stderr, Options{
|
|
LLMClientFactory: fakeLLMFactory(client, nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("seed RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
if client.calls == 0 {
|
|
t.Fatal("seed LLM calls = 0, want checkpoint seed run to execute")
|
|
}
|
|
}
|
|
|
|
func runResumeWithClient(t *testing.T, configPath string, inputPath string, extraArgs []string) *fakeRunLLMClient {
|
|
t.Helper()
|
|
client := newFakeRunLLMClient(false)
|
|
args := []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", t.TempDir(), "--resume"}
|
|
args = append(args, extraArgs...)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := RunWithOptions(args, &stdout, &stderr, Options{
|
|
LLMClientFactory: fakeLLMFactory(client, nil),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("resume RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
return client
|
|
}
|
|
|
|
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 assertPathNotExist(t *testing.T, path string) {
|
|
t.Helper()
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("path %q stat err = %v, want not exist", path, err)
|
|
}
|
|
}
|
|
|
|
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 manifestValidatorChain(t *testing.T, manifest artifacts.RunManifest, stage pipeline.ModuleStage, laneID string, module string) artifacts.ValidatorChainManifest {
|
|
t.Helper()
|
|
for _, chain := range manifest.ValidatorChains {
|
|
if chain.Stage == string(stage) && chain.LaneID == laneID && chain.ModuleKey == module {
|
|
return chain
|
|
}
|
|
}
|
|
t.Fatalf("validator chain %s/%s/%s not found in %#v", stage, laneID, module, manifest.ValidatorChains)
|
|
return artifacts.ValidatorChainManifest{}
|
|
}
|
|
|
|
func manifestValidatorKeys(chain artifacts.ValidatorChainManifest) []string {
|
|
keys := make([]string, 0, len(chain.Validators))
|
|
for _, validator := range chain.Validators {
|
|
keys = append(keys, validator.Key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
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,
|
|
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
|
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 mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, spec pipeline.ValidatorSpec) {
|
|
t.Helper()
|
|
if err := registry.RegisterWithSpec(spec, func() (contracts.Validator, error) {
|
|
return fakeConfigValidator{name: spec.Key, executionClass: spec.ExecutionClass}, nil
|
|
}); err != nil {
|
|
t.Fatalf("register validator: %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeConfigValidator struct {
|
|
name string
|
|
executionClass contracts.ExecutionClass
|
|
}
|
|
|
|
func (validator fakeConfigValidator) Name() string {
|
|
return validator.name
|
|
}
|
|
|
|
func (validator fakeConfigValidator) ExecutionClass() contracts.ExecutionClass {
|
|
return validator.executionClass
|
|
}
|
|
|
|
func (validator fakeConfigValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
|
return contracts.ValidationResult{Approved: true}, nil
|
|
}
|
|
|
|
func mapLookup(values map[string]string) func(string) (string, bool) {
|
|
return func(key string) (string, bool) {
|
|
value, ok := values[key]
|
|
return value, ok
|
|
}
|
|
}
|