Add CLI reference binding flags
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -737,6 +738,217 @@ func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPipelineReferenceFlagBindsUnambiguousSlot(t *testing.T) {
|
||||
configPath := writeTestConfig(t, testConfigYAML("example", "events"))
|
||||
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,
|
||||
"--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 || !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].References
|
||||
want := []pipeline.ReferenceBinding{
|
||||
{LaneID: "events", SlotName: "roster", Source: "./roster.yml", 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")
|
||||
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=./notes.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 || !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.References) != 0 {
|
||||
t.Fatalf("events references = %#v, want none", events.References)
|
||||
}
|
||||
notes := resolvedArtifactLane(t, resolved, "notes")
|
||||
if len(notes.References) != 1 || notes.References[0].Source != "./notes.yml" {
|
||||
t.Fatalf("notes references = %#v, want lane-qualified binding", notes.References)
|
||||
}
|
||||
}
|
||||
|
||||
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 lanes") || !strings.Contains(stderr.String(), "lane.slot") {
|
||||
t.Fatalf("stderr = %q, want ambiguous 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: "too many selector parts", args: []string{"--reference", "a.b.c=./roster.yml"}, want: "lane.slot"},
|
||||
{name: "unbind with equals", args: []string{"--without-reference", "roster=./roster.yml"}, want: "slot or lane.slot"},
|
||||
}
|
||||
|
||||
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 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].References; len(refs) != 0 {
|
||||
t.Fatalf("references = %#v, want unbound optional slot", refs)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1305,6 +1517,27 @@ func testConfigYAMLForPipelines(pipelines map[string][]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func testConfigYAMLWithReferences(pipelineID string, laneID string, references map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("version: 1\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: 1
|
||||
pipelines:
|
||||
@@ -1596,6 +1829,25 @@ func readJSONFile(t *testing.T, path string, out any) {
|
||||
}
|
||||
}
|
||||
|
||||
func readResolvedPipeline(t *testing.T, diagnosticsDir string) pipeline.ResolvedPipeline {
|
||||
t.Helper()
|
||||
runDir := onlyChildDir(t, diagnosticsDir)
|
||||
var resolved pipeline.ResolvedPipeline
|
||||
readJSONFile(t, filepath.Join(runDir, diagnostics.ArtifactResolvedPipeline), &resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolvedArtifactLane(t *testing.T, resolved pipeline.ResolvedPipeline, laneID string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
for _, lane := range resolved.ArtifactLanes {
|
||||
if lane.ID == laneID {
|
||||
return lane
|
||||
}
|
||||
}
|
||||
t.Fatalf("lane %q not found in resolved pipeline", laneID)
|
||||
return pipeline.ResolvedArtifactLane{}
|
||||
}
|
||||
|
||||
func assertNoTemporaryFiles(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
@@ -1611,7 +1863,7 @@ func assertNoTemporaryFiles(t *testing.T, root string) {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeCatalog(t *testing.T) pipeline.ModuleCatalog {
|
||||
func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
inputs := pipeline.NewInputAdapterRegistry()
|
||||
chunkers := pipeline.NewChunkerRegistry()
|
||||
@@ -1621,12 +1873,24 @@ func fakeCatalog(t *testing.T) pipeline.ModuleCatalog {
|
||||
validators := pipeline.NewValidatorRegistry()
|
||||
outputs := pipeline.NewOutputEncoderRegistry()
|
||||
|
||||
mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}})
|
||||
mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}})
|
||||
mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}})
|
||||
mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}})
|
||||
mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}})
|
||||
mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}})
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user