316 lines
9.1 KiB
Go
316 lines
9.1 KiB
Go
package scriptorium
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRenderConstructsCommand(t *testing.T) {
|
|
commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}}
|
|
runner := Runner{
|
|
Binary: "/usr/local/bin/scriptorium",
|
|
ConfigPath: "/etc/scriptorium.yml",
|
|
Profile: "weather",
|
|
Timeout: time.Minute,
|
|
Commands: commands,
|
|
}
|
|
|
|
result, err := runner.Render(context.Background(), RenderRequest{
|
|
PromptID: "weather.markdown_report",
|
|
DataPackagePath: "/tmp/data_package.yaml",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Render() error = %v", err)
|
|
}
|
|
|
|
wantArgs := []string{
|
|
"render",
|
|
"--config", "/etc/scriptorium.yml",
|
|
"--profile", "weather",
|
|
"--prompt", "weather.markdown_report",
|
|
"--input", "data_package=/tmp/data_package.yaml",
|
|
"--format", "json",
|
|
}
|
|
if commands.name != "/usr/local/bin/scriptorium" {
|
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
|
}
|
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
|
}
|
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
|
t.Fatalf("result command = %#v, want full argv", result.Command)
|
|
}
|
|
}
|
|
|
|
func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
|
runner := Runner{
|
|
Commands: &fakeCommands{
|
|
result: CommandResult{
|
|
Stderr: []byte("missing input"),
|
|
ExitCode: 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := runner.Render(context.Background(), RenderRequest{
|
|
PromptID: "weather.markdown_report",
|
|
DataPackagePath: "/tmp/data_package.yaml",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Render() error = nil, want nonzero exit error")
|
|
}
|
|
if result == nil {
|
|
t.Fatal("Render() result = nil, want captured result")
|
|
}
|
|
if result.ExitCode != 1 {
|
|
t.Fatalf("ExitCode = %d, want 1", result.ExitCode)
|
|
}
|
|
if !strings.Contains(err.Error(), "missing input") {
|
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunConstructsCommand(t *testing.T) {
|
|
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
|
|
runner := Runner{
|
|
Binary: "/usr/local/bin/scriptorium",
|
|
ConfigPath: "/etc/scriptorium.yml",
|
|
Profile: "weather",
|
|
Timeout: 45 * time.Second,
|
|
Commands: commands,
|
|
}
|
|
|
|
result, err := runner.Run(context.Background(), RunRequest{
|
|
PromptID: "weather.markdown_report",
|
|
DataPackagePath: "/tmp/data_package.yaml",
|
|
OutputPath: "/tmp/daily.md",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
wantArgs := []string{
|
|
"run",
|
|
"--config", "/etc/scriptorium.yml",
|
|
"--profile", "weather",
|
|
"--prompt", "weather.markdown_report",
|
|
"--input", "data_package=/tmp/data_package.yaml",
|
|
"--out", "/tmp/daily.md",
|
|
}
|
|
if commands.name != "/usr/local/bin/scriptorium" {
|
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
|
}
|
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
|
}
|
|
if commands.timeout != 45*time.Second {
|
|
t.Fatalf("timeout = %s, want 45s", commands.timeout)
|
|
}
|
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
|
t.Fatalf("result command = %#v, want full argv", result.Command)
|
|
}
|
|
if result.OutputPath != "/tmp/daily.md" {
|
|
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|
runner := Runner{
|
|
Commands: &fakeCommands{
|
|
result: CommandResult{
|
|
Stdout: []byte("# Daily Report\n"),
|
|
Stderr: []byte("validation failed"),
|
|
ExitCode: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := runner.Run(context.Background(), RunRequest{
|
|
PromptID: "weather.markdown_report",
|
|
DataPackagePath: "/tmp/data_package.yaml",
|
|
OutputPath: "/tmp/daily.md",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want nonzero exit error")
|
|
}
|
|
if result == nil {
|
|
t.Fatal("Run() result = nil, want captured result")
|
|
}
|
|
if result.ExitCode != 2 {
|
|
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
|
|
}
|
|
if !strings.Contains(err.Error(), "validation failed") {
|
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
|
commands := &fakeCommands{result: CommandResult{
|
|
Stdout: []byte(`{"summary":"ok"}`),
|
|
Stderr: []byte("wrote generated text"),
|
|
StdoutTruncated: true,
|
|
}}
|
|
runner := Runner{
|
|
Binary: "/usr/local/bin/scriptorium",
|
|
ConfigPath: "/etc/scriptorium.yml",
|
|
Profile: "weather",
|
|
Timeout: 30 * time.Second,
|
|
Commands: commands,
|
|
}
|
|
|
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
|
PromptID: "weather.hourly_generated_text",
|
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StructuredRun() error = %v", err)
|
|
}
|
|
|
|
wantArgs := []string{
|
|
"run",
|
|
"--config", "/etc/scriptorium.yml",
|
|
"--profile", "weather",
|
|
"--prompt", "weather.hourly_generated_text",
|
|
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
|
"--out", "/tmp/hourly.generated_text.raw.json",
|
|
}
|
|
if commands.name != "/usr/local/bin/scriptorium" {
|
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
|
}
|
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
|
}
|
|
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
|
|
if containsArg(commands.args, disallowed) {
|
|
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
|
|
}
|
|
}
|
|
if commands.timeout != 30*time.Second {
|
|
t.Fatalf("timeout = %s, want 30s", commands.timeout)
|
|
}
|
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
|
t.Fatalf("result command = %#v, want full argv", result.Command)
|
|
}
|
|
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
|
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
|
}
|
|
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
|
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
|
}
|
|
}
|
|
|
|
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
|
runner := Runner{
|
|
Commands: &fakeCommands{
|
|
result: CommandResult{
|
|
Stdout: []byte(`{"summary":"partial"}`),
|
|
Stderr: []byte("structured output failed"),
|
|
ExitCode: 3,
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
|
PromptID: "weather.hourly_generated_text",
|
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
|
}
|
|
if result == nil {
|
|
t.Fatal("StructuredRun() result = nil, want captured result")
|
|
}
|
|
if result.ExitCode != 3 {
|
|
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
|
}
|
|
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
|
t.Fatalf("result = %#v, want captured result fields", result)
|
|
}
|
|
if !strings.Contains(err.Error(), "structured output failed") {
|
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
req StructuredRunRequest
|
|
want string
|
|
}{
|
|
{
|
|
name: "prompt id",
|
|
req: StructuredRunRequest{
|
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
},
|
|
want: "prompt id is required",
|
|
},
|
|
{
|
|
name: "data package path",
|
|
req: StructuredRunRequest{
|
|
PromptID: "weather.hourly_generated_text",
|
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
},
|
|
want: "data package path is required",
|
|
},
|
|
{
|
|
name: "output path",
|
|
req: StructuredRunRequest{
|
|
PromptID: "weather.hourly_generated_text",
|
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
},
|
|
want: "output path is required",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
commands := &fakeCommands{}
|
|
runner := Runner{Commands: commands}
|
|
result, err := runner.StructuredRun(context.Background(), test.req)
|
|
if err == nil {
|
|
t.Fatal("StructuredRun() error = nil, want validation error")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("StructuredRun() result = %#v, want nil", result)
|
|
}
|
|
if !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
|
|
}
|
|
if commands.calls != 0 {
|
|
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type fakeCommands struct {
|
|
name string
|
|
args []string
|
|
timeout time.Duration
|
|
result CommandResult
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
|
f.calls++
|
|
f.name = name
|
|
f.args = append([]string{}, args...)
|
|
f.timeout = timeout
|
|
return f.result, f.err
|
|
}
|
|
|
|
func containsArg(args []string, want string) bool {
|
|
for _, arg := range args {
|
|
if arg == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|