diff --git a/internal/adapters/seriatim/fake.go b/internal/adapters/seriatim/fake.go index 4133985..66487b1 100644 --- a/internal/adapters/seriatim/fake.go +++ b/internal/adapters/seriatim/fake.go @@ -48,14 +48,37 @@ func (n *NoopRunner) Trim(ctx context.Context, req TrimRequest) (TrimResult, err }, nil } +// Normalize returns the requested output path with placeholder metadata. +func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) { + if err := ctx.Err(); err != nil { + return NormalizeResult{}, err + } + if err := materializeNormalizePlaceholders(req); err != nil { + return NormalizeResult{}, err + } + return NormalizeResult{ + OutputNormalizedPath: req.OutputNormalizedPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + InvokedBinary: "noop", + OutputSchema: req.OutputSchema, + Metadata: map[string]any{"placeholder": true}, + }, nil +} + // FakeRunner captures merge requests and returns deterministic responses. type FakeRunner struct { - Requests []MergeRequest - Err error - Result MergeResult - TrimRequests []TrimRequest - TrimErr error - TrimResult TrimResult + Requests []MergeRequest + Err error + Result MergeResult + NormalizeRequests []NormalizeRequest + NormalizeErr error + NormalizeResult NormalizeResult + TrimRequests []TrimRequest + TrimErr error + TrimResult TrimResult } // Run records request and returns configured response. @@ -132,6 +155,46 @@ func (f *FakeRunner) Trim(ctx context.Context, req TrimRequest) (TrimResult, err return res, nil } +// Normalize records request and returns configured response. +func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) { + if err := ctx.Err(); err != nil { + return NormalizeResult{}, err + } + f.NormalizeRequests = append(f.NormalizeRequests, req) + if f.NormalizeErr != nil { + return NormalizeResult{}, f.NormalizeErr + } + if err := materializeNormalizePlaceholders(req); err != nil { + return NormalizeResult{}, err + } + res := f.NormalizeResult + if res.OutputNormalizedPath == "" { + res.OutputNormalizedPath = req.OutputNormalizedPath + } + if res.ReportPath == "" { + res.ReportPath = req.ReportPath + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } + if res.InvokedBinary == "" { + res.InvokedBinary = "fake" + } + if res.OutputSchema == "" { + res.OutputSchema = req.OutputSchema + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} + func materializePlaceholders(req MergeRequest) error { if req.OutputMergedTranscriptPath != "" { if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil { @@ -198,3 +261,43 @@ func materializeTrimPlaceholders(req TrimRequest) error { } return nil } + +func materializeNormalizePlaceholders(req NormalizeRequest) error { + if req.OutputNormalizedPath != "" { + if err := subprocess.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil { + return fmt.Errorf("write normalized transcript %q: %w", req.OutputNormalizedPath, err) + } + } + if req.GeneratedConfigPath != "" { + payload := map[string]any{ + "schema": "seriatim.generated.v1", + "placeholder": true, + "command": "normalize", + "input_path": req.InputTranscriptPath, + "output_path": req.OutputNormalizedPath, + "output_schema": req.OutputSchema, + } + if req.ReportPath != "" { + payload["report_path"] = req.ReportPath + } + 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("seriatim noop/fake normalize 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("seriatim noop/fake normalize stderr placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) + } + } + if req.ReportPath != "" { + if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), 0o644); err != nil { + return fmt.Errorf("write report %q: %w", req.ReportPath, err) + } + } + return nil +} diff --git a/internal/adapters/seriatim/fake_test.go b/internal/adapters/seriatim/fake_test.go index 8a9c743..b6bb023 100644 --- a/internal/adapters/seriatim/fake_test.go +++ b/internal/adapters/seriatim/fake_test.go @@ -99,3 +99,52 @@ func TestFakeRunnerTrimError(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestFakeRunnerNormalizeCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeRunner{} + dir := t.TempDir() + req := NormalizeRequest{ + GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.normalize.yml"), + InputTranscriptPath: filepath.Join(dir, "transcripts", "processed.json"), + OutputNormalizedPath: filepath.Join(dir, "transcripts", "normalized.json"), + OutputSchema: "seriatim-intermediate", + ReportPath: filepath.Join(dir, "artifacts", "seriatim.normalize.report.json"), + StdoutLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stderr.log"), + } + + res, err := fake.Normalize(context.Background(), req) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if len(fake.NormalizeRequests) != 1 || fake.NormalizeRequests[0].GeneratedConfigPath == "" { + t.Fatalf("normalize requests = %#v, want captured request", fake.NormalizeRequests) + } + if res.OutputNormalizedPath != req.OutputNormalizedPath { + t.Fatalf("normalized path = %q, want %q", res.OutputNormalizedPath, req.OutputNormalizedPath) + } + if res.OutputSchema != req.OutputSchema { + t.Fatalf("output schema = %q, want %q", res.OutputSchema, req.OutputSchema) + } + + cfgData, err := os.ReadFile(req.GeneratedConfigPath) + if err != nil { + t.Fatalf("read generated config: %v", err) + } + if !strings.Contains(string(cfgData), "command: normalize") { + t.Fatalf("generated config = %q, want normalize command marker", string(cfgData)) + } + for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.ReportPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file %q to exist: %v", path, err) + } + } +} + +func TestFakeRunnerNormalizeError(t *testing.T) { + fake := &FakeRunner{NormalizeErr: errors.New("boom")} + _, err := fake.Normalize(context.Background(), NormalizeRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/seriatim/runner.go b/internal/adapters/seriatim/runner.go index 313ba3d..1ecf2a7 100644 --- a/internal/adapters/seriatim/runner.go +++ b/internal/adapters/seriatim/runner.go @@ -1,4 +1,4 @@ -// Package seriatim declares the adapter contract for transcript merge/trim execution. +// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution. package seriatim import ( @@ -6,9 +6,10 @@ import ( "time" ) -// Runner is the adapter boundary for seriatim merge/trim invocations. +// Runner is the adapter boundary for seriatim merge/normalize/trim invocations. type Runner interface { Run(ctx context.Context, req MergeRequest) (MergeResult, error) + Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) Trim(ctx context.Context, req TrimRequest) (TrimResult, error) } @@ -38,6 +39,33 @@ type MergeResult struct { Metadata map[string]any } +// NormalizeRequest describes a seriatim normalize invocation. +type NormalizeRequest struct { + Binary string + InputTranscriptPath string + OutputNormalizedPath string + OutputSchema string + ReportPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + Timeout time.Duration +} + +// NormalizeResult describes a normalize output. +type NormalizeResult struct { + OutputNormalizedPath string + ReportPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + ExitCode int + Duration time.Duration + InvokedBinary string + OutputSchema string + Metadata map[string]any +} + // TrimRequest describes a seriatim trim invocation. type TrimRequest struct { Binary string diff --git a/internal/adapters/seriatim/subprocess.go b/internal/adapters/seriatim/subprocess.go index de13af2..66f1443 100644 --- a/internal/adapters/seriatim/subprocess.go +++ b/internal/adapters/seriatim/subprocess.go @@ -77,10 +77,8 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) if strings.TrimSpace(cfg.OutputSchema) == "" { return nil, fmt.Errorf("seriatim output schema is required") } - switch cfg.OutputSchema { - case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": - default: - return nil, fmt.Errorf("seriatim output schema %q is unsupported", cfg.OutputSchema) + if err := validateOutputSchema(cfg.OutputSchema); err != nil { + return nil, err } if cfg.CoalesceGap != nil && *cfg.CoalesceGap < 0 { return nil, fmt.Errorf("seriatim coalesce gap must be >= 0") @@ -96,6 +94,15 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) }, nil } +func validateOutputSchema(schema string) error { + switch strings.TrimSpace(schema) { + case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": + return nil + default: + return fmt.Errorf("seriatim output schema %q is unsupported", schema) + } +} + // Run executes Seriatim merge with deterministic flags and validates output artifacts. func (r *SubprocessRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) { if r == nil { @@ -271,6 +278,112 @@ func (r *SubprocessRunner) Trim(ctx context.Context, req TrimRequest) (TrimResul }, nil } +// Normalize executes Seriatim normalize with deterministic flags and validates output artifacts. +func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) { + if r == nil { + return NormalizeResult{}, fmt.Errorf("seriatim subprocess runner is nil") + } + if strings.TrimSpace(req.InputTranscriptPath) == "" { + return NormalizeResult{}, fmt.Errorf("seriatim normalize input path is required") + } + if strings.TrimSpace(req.OutputNormalizedPath) == "" { + return NormalizeResult{}, fmt.Errorf("seriatim normalize output path is required") + } + + binary := r.binary + if strings.TrimSpace(req.Binary) != "" { + binary = strings.TrimSpace(req.Binary) + } + + timeout := r.timeout + if req.Timeout < 0 { + return NormalizeResult{}, fmt.Errorf("seriatim normalize timeout must be >= 0") + } + if req.Timeout > 0 { + timeout = req.Timeout + } + + outputSchema := strings.TrimSpace(req.OutputSchema) + if outputSchema == "" { + outputSchema = r.outputSchema + } + if err := validateOutputSchema(outputSchema); err != nil { + return NormalizeResult{}, err + } + + args := buildNormalizeArgs(req, outputSchema) + if req.GeneratedConfigPath != "" { + if err := writeNormalizeInvocationConfig(req, args, binary, timeout, outputSchema); err != nil { + return NormalizeResult{}, fmt.Errorf("write seriatim normalize invocation config %q: %w", req.GeneratedConfigPath, err) + } + } + + runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ + Executable: binary, + Args: args, + Timeout: timeout, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + }) + if err != nil { + return NormalizeResult{ + OutputNormalizedPath: req.OutputNormalizedPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + OutputSchema: outputSchema, + }, fmt.Errorf("run seriatim normalize (binary=%q): %w", binary, err) + } + + if err := validateJSONFileWithSegments(req.OutputNormalizedPath); err != nil { + return NormalizeResult{ + OutputNormalizedPath: req.OutputNormalizedPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + OutputSchema: outputSchema, + }, fmt.Errorf("validate seriatim normalized output %q: %w", req.OutputNormalizedPath, err) + } + if strings.TrimSpace(req.ReportPath) != "" { + if err := validateJSONFile(req.ReportPath); err != nil { + return NormalizeResult{ + OutputNormalizedPath: req.OutputNormalizedPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + OutputSchema: outputSchema, + }, fmt.Errorf("validate seriatim normalize report output %q: %w", req.ReportPath, err) + } + } + + return NormalizeResult{ + OutputNormalizedPath: req.OutputNormalizedPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: binary, + OutputSchema: outputSchema, + Metadata: map[string]any{ + "adapter": "seriatim_subprocess", + }, + }, nil +} + func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string { args := []string{"merge"} @@ -354,6 +467,19 @@ func buildTrimArgs(req TrimRequest) []string { } } +func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string { + args := []string{ + "normalize", + "--input-file", req.InputTranscriptPath, + "--output-file", req.OutputNormalizedPath, + "--output-schema", outputSchema, + } + if strings.TrimSpace(req.ReportPath) != "" { + args = append(args, "--report-file", req.ReportPath) + } + return args +} + func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error { payload := map[string]any{ "schema": "seriatim.generated.v1", @@ -368,6 +494,21 @@ func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, ti return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) } +func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error { + payload := map[string]any{ + "schema": "seriatim.generated.v1", + "command": "normalize", + "binary": binary, + "args": args, + "timeout": timeout.String(), + "input_path": req.InputTranscriptPath, + "output_path": req.OutputNormalizedPath, + "output_schema": outputSchema, + "report_path": req.ReportPath, + } + return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) +} + func validateJSONFile(path string) error { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/adapters/seriatim/subprocess_test.go b/internal/adapters/seriatim/subprocess_test.go index e71405a..789a2b7 100644 --- a/internal/adapters/seriatim/subprocess_test.go +++ b/internal/adapters/seriatim/subprocess_test.go @@ -375,6 +375,200 @@ func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) { } } +func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_success") + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) + + wrapper := writeHelperWrapper(t) + runner := mustRunner(t, wrapper, false) + req := normalizeReqForTest(t, true) + + res, err := runner.Normalize(context.Background(), req) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if res.OutputNormalizedPath != req.OutputNormalizedPath { + t.Fatalf("OutputNormalizedPath = %q, want %q", res.OutputNormalizedPath, req.OutputNormalizedPath) + } + if res.OutputSchema != req.OutputSchema { + t.Fatalf("OutputSchema = %q, want %q", res.OutputSchema, req.OutputSchema) + } + if res.ReportPath != req.ReportPath { + t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath) + } + if res.InvokedBinary != wrapper { + t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.Duration <= 0 { + t.Fatalf("Duration = %s, want >0", res.Duration) + } + if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" { + t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata) + } + + assertJSONFile(t, req.OutputNormalizedPath) + assertJSONFile(t, req.ReportPath) + if _, err := os.Stat(req.StdoutLogPath); err != nil { + t.Fatalf("stdout log missing: %v", err) + } + if _, err := os.Stat(req.StderrLogPath); err != nil { + t.Fatalf("stderr log missing: %v", err) + } + if _, err := os.Stat(req.GeneratedConfigPath); err != nil { + t.Fatalf("generated config missing: %v", err) + } + + cfgData, err := os.ReadFile(req.GeneratedConfigPath) + if err != nil { + t.Fatalf("read generated config: %v", err) + } + cfgText := string(cfgData) + if !strings.Contains(cfgText, "command: normalize") { + t.Fatalf("generated config = %q, want command: normalize", cfgText) + } + if !strings.Contains(cfgText, "output_schema: seriatim-intermediate") { + t.Fatalf("generated config = %q, want output schema", cfgText) + } + + rec := readHelperRecord(t, recordPath) + wantArgs := []string{ + "normalize", + "--input-file", req.InputTranscriptPath, + "--output-file", req.OutputNormalizedPath, + "--output-schema", req.OutputSchema, + "--report-file", req.ReportPath, + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } +} + +func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "fail") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, false) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "run seriatim normalize") { + t.Fatalf("error = %q, want subprocess context", err.Error()) + } + if !strings.Contains(err.Error(), "exit code") { + t.Fatalf("error = %q, want exit code context", err.Error()) + } +} + +func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, false) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim normalized output") { + t.Fatalf("error = %q, want output validation context", err.Error()) + } +} + +func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, false) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "parse json") { + t.Fatalf("error = %q, want parse json context", err.Error()) + } +} + +func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_segments") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, false) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "top-level segments is required") { + t.Fatalf("error = %q, want missing segments context", err.Error()) + } +} + +func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_report_missing") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, true) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim normalize report output") { + t.Fatalf("error = %q, want report validation context", err.Error()) + } +} + +func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_report") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := normalizeReqForTest(t, true) + _, err := runner.Normalize(context.Background(), req) + if err == nil { + t.Fatal("Normalize() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim normalize report output") { + t.Fatalf("error = %q, want report validation context", err.Error()) + } +} + func TestSubprocessRunnerConstructorValidation(t *testing.T) { _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) if err == nil { @@ -451,6 +645,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) { _, _ = os.Stdout.WriteString("seriatim helper trim stdout\n") _, _ = os.Stderr.WriteString("seriatim helper trim stderr\n") os.Exit(0) + case "normalize_success": + writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"schema":"seriatim.normalize.report.v1","ok":true}`) + } + _, _ = os.Stdout.WriteString("seriatim helper normalize stdout\n") + _, _ = os.Stderr.WriteString("seriatim helper normalize stderr\n") + os.Exit(0) case "fail": _, _ = os.Stderr.WriteString("seriatim helper failure\n") os.Exit(9) @@ -459,21 +661,47 @@ func TestSeriatimSubprocessHelper(t *testing.T) { writeSeriatimHelperFile(reportPath, `{"report":true}`) } os.Exit(0) + case "normalize_missing_output": + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"schema":"seriatim.normalize.report.v1","ok":true}`) + } + os.Exit(0) case "invalid_output": writeSeriatimHelperFile(outputPath, `not-json`) if reportPath != "" { writeSeriatimHelperFile(reportPath, `{"report":true}`) } os.Exit(0) + case "normalize_invalid_output": + writeSeriatimHelperFile(outputPath, `not-json`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"schema":"seriatim.normalize.report.v1","ok":true}`) + } + os.Exit(0) case "invalid_report": writeSeriatimHelperFile(outputPath, `{"merged":true}`) if reportPath != "" { writeSeriatimHelperFile(reportPath, `not-json`) } os.Exit(0) + case "normalize_invalid_report": + writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `not-json`) + } + os.Exit(0) case "trim_missing_segments": writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1"}`) os.Exit(0) + case "normalize_missing_segments": + writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1"}`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"schema":"seriatim.normalize.report.v1","ok":true}`) + } + os.Exit(0) + case "normalize_report_missing": + writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) + os.Exit(0) default: _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) os.Exit(2) @@ -529,6 +757,26 @@ func trimReqForTest(t *testing.T) TrimRequest { } } +func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest { + t.Helper() + dir := t.TempDir() + input := filepath.Join(dir, "processed.json") + writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`) + + req := NormalizeRequest{ + InputTranscriptPath: input, + OutputNormalizedPath: filepath.Join(dir, "normalized.json"), + OutputSchema: "seriatim-intermediate", + GeneratedConfigPath: filepath.Join(dir, "seriatim.normalize.generated.yml"), + StdoutLogPath: filepath.Join(dir, "seriatim.normalize.stdout.log"), + StderrLogPath: filepath.Join(dir, "seriatim.normalize.stderr.log"), + } + if withReport { + req.ReportPath = filepath.Join(dir, "seriatim.normalize.report.json") + } + return req +} + func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner { t.Helper() coalesce := 3.0