Implement Scriptorium subprocess adapter
This commit is contained in:
203
internal/adapters/scriptorium/fake.go
Normal file
203
internal/adapters/scriptorium/fake.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
|
||||
// NoopRunner is a deterministic no-op scriptorium adapter.
|
||||
type NoopRunner struct{}
|
||||
|
||||
// RunArtifact returns requested output path with placeholder metadata.
|
||||
func (n *NoopRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := materializeRunPlaceholders(req); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
return ArtifactResult{
|
||||
OutputPath: req.OutputPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
CommandMode: CommandModeRun,
|
||||
PromptID: req.PromptID,
|
||||
ProfileID: req.ProfileID,
|
||||
Metadata: map[string]any{"placeholder": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenderArtifact returns requested output path with placeholder metadata.
|
||||
func (n *NoopRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
if err := materializeRenderPlaceholders(req); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
return ArtifactResult{
|
||||
OutputPath: req.OutputPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
CommandMode: CommandModeRender,
|
||||
PromptID: req.PromptID,
|
||||
ProfileID: req.ProfileID,
|
||||
Metadata: map[string]any{"placeholder": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FakeRunner captures requests and returns deterministic responses.
|
||||
type FakeRunner struct {
|
||||
RunRequests []RunArtifactRequest
|
||||
RenderRequests []RenderArtifactRequest
|
||||
RunErr error
|
||||
RenderErr error
|
||||
RunResult ArtifactResult
|
||||
RenderResult ArtifactResult
|
||||
}
|
||||
|
||||
// RunArtifact records request and returns configured response.
|
||||
func (f *FakeRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
f.RunRequests = append(f.RunRequests, req)
|
||||
if f.RunErr != nil {
|
||||
return ArtifactResult{}, f.RunErr
|
||||
}
|
||||
if err := materializeRunPlaceholders(req); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
res := f.RunResult
|
||||
if res.OutputPath == "" {
|
||||
res.OutputPath = req.OutputPath
|
||||
}
|
||||
if res.StdoutLogPath == "" {
|
||||
res.StdoutLogPath = req.StdoutLogPath
|
||||
}
|
||||
if res.StderrLogPath == "" {
|
||||
res.StderrLogPath = req.StderrLogPath
|
||||
}
|
||||
if res.GeneratedConfigPath == "" {
|
||||
res.GeneratedConfigPath = req.GeneratedConfigPath
|
||||
}
|
||||
if res.CommandMode == "" {
|
||||
res.CommandMode = CommandModeRun
|
||||
}
|
||||
if res.PromptID == "" {
|
||||
res.PromptID = req.PromptID
|
||||
}
|
||||
if res.ProfileID == "" {
|
||||
res.ProfileID = req.ProfileID
|
||||
}
|
||||
if res.Metadata == nil {
|
||||
res.Metadata = map[string]any{"fake": true}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RenderArtifact records request and returns configured response.
|
||||
func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
f.RenderRequests = append(f.RenderRequests, req)
|
||||
if f.RenderErr != nil {
|
||||
return ArtifactResult{}, f.RenderErr
|
||||
}
|
||||
if err := materializeRenderPlaceholders(req); err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
res := f.RenderResult
|
||||
if res.OutputPath == "" {
|
||||
res.OutputPath = req.OutputPath
|
||||
}
|
||||
if res.StdoutLogPath == "" {
|
||||
res.StdoutLogPath = req.StdoutLogPath
|
||||
}
|
||||
if res.StderrLogPath == "" {
|
||||
res.StderrLogPath = req.StderrLogPath
|
||||
}
|
||||
if res.GeneratedConfigPath == "" {
|
||||
res.GeneratedConfigPath = req.GeneratedConfigPath
|
||||
}
|
||||
if res.CommandMode == "" {
|
||||
res.CommandMode = CommandModeRender
|
||||
}
|
||||
if res.PromptID == "" {
|
||||
res.PromptID = req.PromptID
|
||||
}
|
||||
if res.ProfileID == "" {
|
||||
res.ProfileID = req.ProfileID
|
||||
}
|
||||
if res.Metadata == nil {
|
||||
res.Metadata = map[string]any{"fake": true}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func materializeRunPlaceholders(req RunArtifactRequest) error {
|
||||
if req.OutputPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write run output %q: %w", req.OutputPath, err)
|
||||
}
|
||||
}
|
||||
if req.GeneratedConfigPath != "" {
|
||||
payload := map[string]any{
|
||||
"schema": "scriptorium.generated.v1",
|
||||
"placeholder": true,
|
||||
"mode": CommandModeRun,
|
||||
"prompt_id": req.PromptID,
|
||||
"output_path": req.OutputPath,
|
||||
}
|
||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
if req.StdoutLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||
}
|
||||
}
|
||||
if req.StderrLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func materializeRenderPlaceholders(req RenderArtifactRequest) error {
|
||||
if req.OutputPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write render output %q: %w", req.OutputPath, err)
|
||||
}
|
||||
}
|
||||
if req.GeneratedConfigPath != "" {
|
||||
payload := map[string]any{
|
||||
"schema": "scriptorium.generated.v1",
|
||||
"placeholder": true,
|
||||
"mode": CommandModeRender,
|
||||
"prompt_id": req.PromptID,
|
||||
"output_path": req.OutputPath,
|
||||
}
|
||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
if req.StdoutLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||
}
|
||||
}
|
||||
if req.StderrLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
internal/adapters/scriptorium/fake_test.go
Normal file
77
internal/adapters/scriptorium/fake_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeRunnerCapturesRunAndRenderRequests(t *testing.T) {
|
||||
fake := &FakeRunner{}
|
||||
dir := t.TempDir()
|
||||
|
||||
runReq := RunArtifactRequest{
|
||||
Binary: "scriptorium",
|
||||
PromptID: "dnd.session_recap",
|
||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"),
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
runRes, err := fake.RunArtifact(context.Background(), runReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RunArtifact() error = %v", err)
|
||||
}
|
||||
if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != runReq.PromptID {
|
||||
t.Fatalf("run requests = %#v, want captured request", fake.RunRequests)
|
||||
}
|
||||
if runRes.OutputPath != runReq.OutputPath {
|
||||
t.Fatalf("run output path = %q, want %q", runRes.OutputPath, runReq.OutputPath)
|
||||
}
|
||||
|
||||
renderReq := RenderArtifactRequest{
|
||||
Binary: "scriptorium",
|
||||
PromptID: "dnd.session_recap",
|
||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"),
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.render.generated.yml"),
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
renderRes, err := fake.RenderArtifact(context.Background(), renderReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderArtifact() error = %v", err)
|
||||
}
|
||||
if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].PromptID != renderReq.PromptID {
|
||||
t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests)
|
||||
}
|
||||
if renderRes.OutputPath != renderReq.OutputPath {
|
||||
t.Fatalf("render output path = %q, want %q", renderRes.OutputPath, renderReq.OutputPath)
|
||||
}
|
||||
|
||||
cfgData, err := os.ReadFile(runReq.GeneratedConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cfgData), "placeholder: true") {
|
||||
t.Fatalf("generated config = %q, want placeholder marker", string(cfgData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRunnerError(t *testing.T) {
|
||||
fake := &FakeRunner{
|
||||
RunErr: errors.New("run boom"),
|
||||
RenderErr: errors.New("render boom"),
|
||||
}
|
||||
if _, err := fake.RunArtifact(context.Background(), RunArtifactRequest{}); err == nil {
|
||||
t.Fatal("expected run error, got nil")
|
||||
}
|
||||
if _, err := fake.RenderArtifact(context.Background(), RenderArtifactRequest{}); err == nil {
|
||||
t.Fatal("expected render error, got nil")
|
||||
}
|
||||
}
|
||||
69
internal/adapters/scriptorium/runner.go
Normal file
69
internal/adapters/scriptorium/runner.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package scriptorium declares the adapter contract for Scriptorium CLI invocations.
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// CommandModeRun is the Scriptorium CLI mode for production generation.
|
||||
CommandModeRun = "run"
|
||||
// CommandModeRender is the Scriptorium CLI mode for debug/test rendering.
|
||||
CommandModeRender = "render"
|
||||
)
|
||||
|
||||
// Runner is the adapter boundary for Scriptorium artifact invocations.
|
||||
type Runner interface {
|
||||
RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error)
|
||||
RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error)
|
||||
}
|
||||
|
||||
// RunArtifactRequest describes a production artifact-generation invocation.
|
||||
type RunArtifactRequest struct {
|
||||
Binary string
|
||||
ConfigPath string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
InputPaths map[string]string
|
||||
Vars map[string]string
|
||||
OutputPath string
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
GeneratedConfigPath string
|
||||
Timeout time.Duration
|
||||
APIKeyEnv string
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
// RenderArtifactRequest describes a render-debug invocation.
|
||||
type RenderArtifactRequest struct {
|
||||
Binary string
|
||||
ConfigPath string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
InputPaths map[string]string
|
||||
Vars map[string]string
|
||||
OutputPath string
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
GeneratedConfigPath string
|
||||
Timeout time.Duration
|
||||
APIKeyEnv string
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
// ArtifactResult describes Scriptorium invocation output and provenance.
|
||||
type ArtifactResult struct {
|
||||
OutputPath string
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
GeneratedConfigPath string
|
||||
ExitCode int
|
||||
Duration time.Duration
|
||||
CommandMode string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
ValidationFailed bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
364
internal/adapters/scriptorium/subprocess.go
Normal file
364
internal/adapters/scriptorium/subprocess.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
|
||||
// SubprocessRunner invokes Scriptorium through its public CLI.
|
||||
type SubprocessRunner struct{}
|
||||
|
||||
// NewSubprocessRunner constructs a Scriptorium subprocess runner.
|
||||
func NewSubprocessRunner() *SubprocessRunner {
|
||||
return &SubprocessRunner{}
|
||||
}
|
||||
|
||||
// RunArtifact executes `scriptorium run` and validates output file creation.
|
||||
func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) {
|
||||
if r == nil {
|
||||
return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil")
|
||||
}
|
||||
credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars)
|
||||
if err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
|
||||
args := buildRunArgs(req)
|
||||
if req.GeneratedConfigPath != "" {
|
||||
if err := writeInvocationConfig(req.GeneratedConfigPath, invocationPayload{
|
||||
Mode: CommandModeRun,
|
||||
Binary: req.Binary,
|
||||
Args: args,
|
||||
Timeout: req.Timeout,
|
||||
ConfigPath: strings.TrimSpace(req.ConfigPath),
|
||||
PromptID: strings.TrimSpace(req.PromptID),
|
||||
ProfileID: strings.TrimSpace(req.ProfileID),
|
||||
InputPaths: cloneStringMap(req.InputPaths),
|
||||
Vars: cloneStringMap(req.Vars),
|
||||
OutputPath: req.OutputPath,
|
||||
APIKeyEnv: strings.TrimSpace(req.APIKeyEnv),
|
||||
APIKeyEnvPresent: credentialPresent,
|
||||
WorkingDir: req.WorkingDir,
|
||||
RenderFormat: "",
|
||||
RenderPromptStore: false,
|
||||
}); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("write scriptorium invocation config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDir,
|
||||
Timeout: req.Timeout,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
})
|
||||
|
||||
result := ArtifactResult{
|
||||
OutputPath: req.OutputPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
ExitCode: runRes.ExitCode,
|
||||
Duration: runRes.Duration,
|
||||
CommandMode: CommandModeRun,
|
||||
PromptID: req.PromptID,
|
||||
ProfileID: req.ProfileID,
|
||||
Metadata: map[string]any{
|
||||
"adapter": "scriptorium_subprocess",
|
||||
"config_path": strings.TrimSpace(req.ConfigPath),
|
||||
"inputs_count": len(req.InputPaths),
|
||||
"vars_count": len(req.Vars),
|
||||
"api_key_env": strings.TrimSpace(req.APIKeyEnv),
|
||||
"api_key_env_present": credentialPresent,
|
||||
},
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
if runRes.ExitCode == 2 {
|
||||
result.ValidationFailed = true
|
||||
exists, size, statErr := fileExistsWithSize(req.OutputPath)
|
||||
if statErr == nil {
|
||||
result.Metadata["output_exists"] = exists
|
||||
result.Metadata["output_size_bytes"] = size
|
||||
}
|
||||
return result, fmt.Errorf("scriptorium run exited with validation failure (exit code 2): %w", runErr)
|
||||
}
|
||||
return result, fmt.Errorf("run scriptorium artifact (binary=%q): %w", req.Binary, runErr)
|
||||
}
|
||||
|
||||
if err := validateNonEmptyOutput(req.OutputPath); err != nil {
|
||||
return result, fmt.Errorf("validate scriptorium run output %q: %w", req.OutputPath, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenderArtifact executes `scriptorium render` with JSON output.
|
||||
func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) {
|
||||
if r == nil {
|
||||
return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil")
|
||||
}
|
||||
credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars)
|
||||
if err != nil {
|
||||
return ArtifactResult{}, err
|
||||
}
|
||||
|
||||
args := buildRenderArgs(req)
|
||||
if req.GeneratedConfigPath != "" {
|
||||
if err := writeInvocationConfig(req.GeneratedConfigPath, invocationPayload{
|
||||
Mode: CommandModeRender,
|
||||
Binary: req.Binary,
|
||||
Args: args,
|
||||
Timeout: req.Timeout,
|
||||
ConfigPath: strings.TrimSpace(req.ConfigPath),
|
||||
PromptID: strings.TrimSpace(req.PromptID),
|
||||
ProfileID: strings.TrimSpace(req.ProfileID),
|
||||
InputPaths: cloneStringMap(req.InputPaths),
|
||||
Vars: cloneStringMap(req.Vars),
|
||||
OutputPath: req.OutputPath,
|
||||
APIKeyEnv: strings.TrimSpace(req.APIKeyEnv),
|
||||
APIKeyEnvPresent: credentialPresent,
|
||||
WorkingDir: req.WorkingDir,
|
||||
RenderFormat: "json",
|
||||
RenderPromptStore: false,
|
||||
}); err != nil {
|
||||
return ArtifactResult{}, fmt.Errorf("write scriptorium invocation config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDir,
|
||||
Timeout: req.Timeout,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
})
|
||||
|
||||
result := ArtifactResult{
|
||||
OutputPath: req.OutputPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
ExitCode: runRes.ExitCode,
|
||||
Duration: runRes.Duration,
|
||||
CommandMode: CommandModeRender,
|
||||
PromptID: req.PromptID,
|
||||
ProfileID: req.ProfileID,
|
||||
Metadata: map[string]any{
|
||||
"adapter": "scriptorium_subprocess",
|
||||
"config_path": strings.TrimSpace(req.ConfigPath),
|
||||
"inputs_count": len(req.InputPaths),
|
||||
"vars_count": len(req.Vars),
|
||||
"api_key_env": strings.TrimSpace(req.APIKeyEnv),
|
||||
"api_key_env_present": credentialPresent,
|
||||
"format": "json",
|
||||
},
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return result, fmt.Errorf("run scriptorium render (binary=%q): %w", req.Binary, runErr)
|
||||
}
|
||||
if err := validateNonEmptyOutput(req.OutputPath); err != nil {
|
||||
return result, fmt.Errorf("validate scriptorium render output %q: %w", req.OutputPath, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateCommonRunRequest(
|
||||
binary string,
|
||||
configPath string,
|
||||
promptID string,
|
||||
outputPath string,
|
||||
timeout time.Duration,
|
||||
apiKeyEnv string,
|
||||
inputPaths map[string]string,
|
||||
vars map[string]string,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(binary) == "" {
|
||||
return false, fmt.Errorf("scriptorium binary is required")
|
||||
}
|
||||
if strings.TrimSpace(configPath) == "" && configPath != "" {
|
||||
return false, fmt.Errorf("scriptorium config path must be non-empty when provided")
|
||||
}
|
||||
if strings.TrimSpace(promptID) == "" {
|
||||
return false, fmt.Errorf("scriptorium prompt id is required")
|
||||
}
|
||||
if strings.TrimSpace(outputPath) == "" {
|
||||
return false, fmt.Errorf("scriptorium output path is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return false, fmt.Errorf("scriptorium timeout must be > 0")
|
||||
}
|
||||
for name, path := range inputPaths {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return false, fmt.Errorf("scriptorium input names must be non-empty")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false, fmt.Errorf("scriptorium input %q path is required", name)
|
||||
}
|
||||
}
|
||||
for name := range vars {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return false, fmt.Errorf("scriptorium variable names must be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
trimmedEnv := strings.TrimSpace(apiKeyEnv)
|
||||
if trimmedEnv == "" {
|
||||
return false, nil
|
||||
}
|
||||
value, ok := os.LookupEnv(trimmedEnv)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return false, fmt.Errorf("scriptorium required credential environment variable %s is not set", trimmedEnv)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func buildRunArgs(req RunArtifactRequest) []string {
|
||||
args := []string{"run", "--prompt", strings.TrimSpace(req.PromptID)}
|
||||
if cfgPath := strings.TrimSpace(req.ConfigPath); cfgPath != "" {
|
||||
args = append(args, "--config", cfgPath)
|
||||
}
|
||||
if profileID := strings.TrimSpace(req.ProfileID); profileID != "" {
|
||||
args = append(args, "--profile", profileID)
|
||||
}
|
||||
for _, kv := range sortedKeyValues(req.InputPaths) {
|
||||
args = append(args, "--input", kv)
|
||||
}
|
||||
for _, kv := range sortedKeyValues(req.Vars) {
|
||||
args = append(args, "--var", kv)
|
||||
}
|
||||
if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" {
|
||||
args = append(args, "--api-key-env", envName)
|
||||
}
|
||||
args = append(args, "--timeout", req.Timeout.String())
|
||||
args = append(args, "--out", req.OutputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func buildRenderArgs(req RenderArtifactRequest) []string {
|
||||
args := []string{"render", "--prompt", strings.TrimSpace(req.PromptID)}
|
||||
if cfgPath := strings.TrimSpace(req.ConfigPath); cfgPath != "" {
|
||||
args = append(args, "--config", cfgPath)
|
||||
}
|
||||
if profileID := strings.TrimSpace(req.ProfileID); profileID != "" {
|
||||
args = append(args, "--profile", profileID)
|
||||
}
|
||||
for _, kv := range sortedKeyValues(req.InputPaths) {
|
||||
args = append(args, "--input", kv)
|
||||
}
|
||||
for _, kv := range sortedKeyValues(req.Vars) {
|
||||
args = append(args, "--var", kv)
|
||||
}
|
||||
if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" {
|
||||
args = append(args, "--api-key-env", envName)
|
||||
}
|
||||
args = append(args, "--timeout", req.Timeout.String())
|
||||
args = append(args, "--format", "json")
|
||||
args = append(args, "--out", req.OutputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func sortedKeyValues(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+values[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type invocationPayload struct {
|
||||
Mode string
|
||||
Binary string
|
||||
Args []string
|
||||
Timeout time.Duration
|
||||
ConfigPath string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
InputPaths map[string]string
|
||||
Vars map[string]string
|
||||
OutputPath string
|
||||
APIKeyEnv string
|
||||
APIKeyEnvPresent bool
|
||||
WorkingDir string
|
||||
RenderFormat string
|
||||
RenderPromptStore bool
|
||||
}
|
||||
|
||||
func writeInvocationConfig(path string, payload invocationPayload) error {
|
||||
data := map[string]any{
|
||||
"schema": "scriptorium.generated.v1",
|
||||
"mode": payload.Mode,
|
||||
"binary": payload.Binary,
|
||||
"args": payload.Args,
|
||||
"timeout": payload.Timeout.String(),
|
||||
"config_path": payload.ConfigPath,
|
||||
"prompt_id": payload.PromptID,
|
||||
"profile_id": payload.ProfileID,
|
||||
"input_paths": payload.InputPaths,
|
||||
"vars": payload.Vars,
|
||||
"output_path": payload.OutputPath,
|
||||
"api_key_env": payload.APIKeyEnv,
|
||||
"api_key_env_present": payload.APIKeyEnvPresent,
|
||||
"working_dir": payload.WorkingDir,
|
||||
"render_format": payload.RenderFormat,
|
||||
"render_prompt_logged": payload.RenderPromptStore,
|
||||
}
|
||||
return subprocess.WriteYAMLAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
func validateNonEmptyOutput(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path is a directory")
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
return fmt.Errorf("file is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExistsWithSize(path string) (bool, int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, 0, nil
|
||||
}
|
||||
return false, 0, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return false, 0, nil
|
||||
}
|
||||
return true, info.Size(), nil
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
386
internal/adapters/scriptorium/subprocess_test.go
Normal file
386
internal/adapters/scriptorium/subprocess_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
|
||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success")
|
||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath)
|
||||
|
||||
runner := NewSubprocessRunner()
|
||||
wrapper := writeScriptoriumHelperWrapper(t)
|
||||
dir := t.TempDir()
|
||||
req := RunArtifactRequest{
|
||||
Binary: wrapper,
|
||||
ConfigPath: "/etc/scriptorium/config.yml",
|
||||
PromptID: "dnd.session_recap",
|
||||
ProfileID: "local-quality",
|
||||
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json"), "other": filepath.Join(dir, "other.md")},
|
||||
Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"},
|
||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"),
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"),
|
||||
Timeout: mustParseScriptoriumDuration(t, "2s"),
|
||||
APIKeyEnv: "OPENAI_KEY_SOURCE",
|
||||
}
|
||||
writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`)
|
||||
writeScriptoriumFile(t, req.InputPaths["other"], "notes\n")
|
||||
|
||||
res, err := runner.RunArtifact(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("RunArtifact() error = %v", err)
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if res.CommandMode != CommandModeRun {
|
||||
t.Fatalf("CommandMode = %q, want %q", res.CommandMode, CommandModeRun)
|
||||
}
|
||||
if res.OutputPath != req.OutputPath {
|
||||
t.Fatalf("OutputPath = %q, want %q", res.OutputPath, req.OutputPath)
|
||||
}
|
||||
if res.Duration <= 0 {
|
||||
t.Fatalf("Duration = %s, want > 0", res.Duration)
|
||||
}
|
||||
|
||||
assertFileNonEmpty(t, req.OutputPath)
|
||||
assertFileContains(t, req.StdoutLogPath, "scriptorium helper stdout")
|
||||
assertFileContains(t, req.StderrLogPath, "scriptorium helper stderr")
|
||||
|
||||
rec := readScriptoriumHelperRecord(t, recordPath)
|
||||
wantArgs := []string{
|
||||
"run",
|
||||
"--prompt", "dnd.session_recap",
|
||||
"--config", "/etc/scriptorium/config.yml",
|
||||
"--profile", "local-quality",
|
||||
"--input", "other=" + req.InputPaths["other"],
|
||||
"--input", "transcript=" + req.InputPaths["transcript"],
|
||||
"--var", "campaign_name=Icewind Dale",
|
||||
"--var", "session_id=2026-05-03",
|
||||
"--api-key-env", "OPENAI_KEY_SOURCE",
|
||||
"--timeout", "2s",
|
||||
"--out", req.OutputPath,
|
||||
}
|
||||
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
|
||||
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
|
||||
}
|
||||
if rec.Env["OPENAI_KEY_SOURCE"] != "super-secret" {
|
||||
t.Fatalf("OPENAI_KEY_SOURCE env = %q, want inherited value", rec.Env["OPENAI_KEY_SOURCE"])
|
||||
}
|
||||
|
||||
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated config: %v", err)
|
||||
}
|
||||
if strings.Contains(string(cfgData), "super-secret") {
|
||||
t.Fatalf("generated config must not include credential value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := NewSubprocessRunner()
|
||||
req := runReqForTest(t, writeScriptoriumHelperWrapper(t))
|
||||
res, err := runner.RunArtifact(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("RunArtifact() error = nil, want non-nil")
|
||||
}
|
||||
if res.ExitCode != 1 {
|
||||
t.Fatalf("ExitCode = %d, want 1", res.ExitCode)
|
||||
}
|
||||
if res.ValidationFailed {
|
||||
t.Fatalf("ValidationFailed = true, want false")
|
||||
}
|
||||
assertFileContains(t, req.StderrLogPath, "scriptorium helper failure")
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutput(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit2_with_output")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := NewSubprocessRunner()
|
||||
req := runReqForTest(t, writeScriptoriumHelperWrapper(t))
|
||||
res, err := runner.RunArtifact(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("RunArtifact() error = nil, want non-nil")
|
||||
}
|
||||
if res.ExitCode != 2 {
|
||||
t.Fatalf("ExitCode = %d, want 2", res.ExitCode)
|
||||
}
|
||||
if !res.ValidationFailed {
|
||||
t.Fatalf("ValidationFailed = false, want true")
|
||||
}
|
||||
if res.OutputPath != req.OutputPath {
|
||||
t.Fatalf("OutputPath = %q, want %q", res.OutputPath, req.OutputPath)
|
||||
}
|
||||
assertFileNonEmpty(t, req.OutputPath)
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success_missing_output")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := NewSubprocessRunner()
|
||||
req := runReqForTest(t, writeScriptoriumHelperWrapper(t))
|
||||
res, err := runner.RunArtifact(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("RunArtifact() error = nil, want non-nil")
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "validate scriptorium run output") {
|
||||
t.Fatalf("error = %q, want output validation context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRenderSuccess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "render_success")
|
||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath)
|
||||
|
||||
runner := NewSubprocessRunner()
|
||||
wrapper := writeScriptoriumHelperWrapper(t)
|
||||
dir := t.TempDir()
|
||||
req := RenderArtifactRequest{
|
||||
Binary: wrapper,
|
||||
PromptID: "dnd.session_recap",
|
||||
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json")},
|
||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"),
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"),
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.render.generated.yml"),
|
||||
Timeout: mustParseScriptoriumDuration(t, "2s"),
|
||||
}
|
||||
writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`)
|
||||
|
||||
res, err := runner.RenderArtifact(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderArtifact() error = %v", err)
|
||||
}
|
||||
if res.CommandMode != CommandModeRender {
|
||||
t.Fatalf("CommandMode = %q, want %q", res.CommandMode, CommandModeRender)
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
assertFileNonEmpty(t, req.OutputPath)
|
||||
|
||||
rec := readScriptoriumHelperRecord(t, recordPath)
|
||||
wantArgs := []string{
|
||||
"render",
|
||||
"--prompt", "dnd.session_recap",
|
||||
"--input", "transcript=" + req.InputPaths["transcript"],
|
||||
"--timeout", "2s",
|
||||
"--format", "json",
|
||||
"--out", req.OutputPath,
|
||||
}
|
||||
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
|
||||
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumSubprocessHelper(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_SCRIPTORIUM_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
args := os.Args
|
||||
start := -1
|
||||
for i := range args {
|
||||
if args[i] == "--" {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 || start >= len(args) {
|
||||
_, _ = os.Stderr.WriteString("missing -- args separator\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
cliArgs := args[start:]
|
||||
|
||||
outputPath := flagValue(cliArgs, "--out")
|
||||
recordPath := os.Getenv("SCRIPTORIUM_HELPER_RECORD_PATH")
|
||||
if strings.TrimSpace(recordPath) != "" {
|
||||
rec := scriptoriumHelperRecord{
|
||||
Args: cliArgs,
|
||||
Env: map[string]string{
|
||||
"OPENAI_KEY_SOURCE": os.Getenv("OPENAI_KEY_SOURCE"),
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(rec)
|
||||
_ = os.MkdirAll(filepath.Dir(recordPath), 0o755)
|
||||
_ = os.WriteFile(recordPath, data, 0o644)
|
||||
}
|
||||
|
||||
mode := os.Getenv("SCRIPTORIUM_HELPER_MODE")
|
||||
switch mode {
|
||||
case "run_success":
|
||||
writeScriptoriumHelperFile(outputPath, "generated artifact\n")
|
||||
_, _ = os.Stdout.WriteString("scriptorium helper stdout\n")
|
||||
_, _ = os.Stderr.WriteString("scriptorium helper stderr\n")
|
||||
os.Exit(0)
|
||||
case "run_success_missing_output":
|
||||
_, _ = os.Stdout.WriteString("scriptorium helper stdout missing output\n")
|
||||
_, _ = os.Stderr.WriteString("scriptorium helper stderr missing output\n")
|
||||
os.Exit(0)
|
||||
case "fail_exit1":
|
||||
_, _ = os.Stderr.WriteString("scriptorium helper failure\n")
|
||||
os.Exit(1)
|
||||
case "fail_exit2_with_output":
|
||||
writeScriptoriumHelperFile(outputPath, "validation failed artifact\n")
|
||||
_, _ = os.Stderr.WriteString("scriptorium helper validation failure\n")
|
||||
os.Exit(2)
|
||||
case "render_success":
|
||||
writeScriptoriumHelperFile(outputPath, "{\"rendered\":true}\n")
|
||||
_, _ = os.Stdout.WriteString("scriptorium helper render stdout\n")
|
||||
_, _ = os.Stderr.WriteString("scriptorium helper render stderr\n")
|
||||
os.Exit(0)
|
||||
default:
|
||||
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptoriumHelperRecord struct {
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
func runReqForTest(t *testing.T, binary string) RunArtifactRequest {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
transcriptPath := filepath.Join(dir, "processed.json")
|
||||
writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`)
|
||||
return RunArtifactRequest{
|
||||
Binary: binary,
|
||||
PromptID: "dnd.session_recap",
|
||||
InputPaths: map[string]string{"transcript": transcriptPath},
|
||||
Vars: map[string]string{"output_kind": "session_recap"},
|
||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"),
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.generated.yml"),
|
||||
Timeout: mustParseScriptoriumDuration(t, "2s"),
|
||||
}
|
||||
}
|
||||
|
||||
func writeScriptoriumHelperWrapper(t *testing.T) string {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Executable() error = %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "scriptorium-helper-wrapper.sh")
|
||||
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestScriptoriumSubprocessHelper -- \"$@\"\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mustParseScriptoriumDuration(t *testing.T, v string) time.Duration {
|
||||
t.Helper()
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
t.Fatalf("time.ParseDuration(%q) error = %v", v, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func flagValue(args []string, name string) string {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == name {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeScriptoriumFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeScriptoriumHelperFile(path, contents string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(contents), 0o644)
|
||||
}
|
||||
|
||||
func assertFileNonEmpty(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(%q): %v", path, err)
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
t.Fatalf("file %q is empty", path)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, wantSubstring string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q): %v", path, err)
|
||||
}
|
||||
if !strings.Contains(string(data), wantSubstring) {
|
||||
t.Fatalf("file %q contents = %q, want substring %q", path, string(data), wantSubstring)
|
||||
}
|
||||
}
|
||||
|
||||
func readScriptoriumHelperRecord(t *testing.T, path string) scriptoriumHelperRecord {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q): %v", path, err)
|
||||
}
|
||||
var rec scriptoriumHelperRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
t.Fatalf("json unmarshal helper record: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
Reference in New Issue
Block a user