package seriatim import ( "context" "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" ) func TestSubprocessRunnerSuccessWithReportArgsAndEnv(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", "success") recordPath := filepath.Join(t.TempDir(), "record.json") t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) wrapper := writeHelperWrapper(t) coalesce := 3.0 owg := 1.0 owrw := 1.0 bmd := 2.0 fmd := 1.25 runner, err := NewSubprocessRunner(SubprocessRunnerConfig{ Binary: wrapper, Timeout: mustParseDuration(t, "2s"), OutputSchema: "seriatim-intermediate", CoalesceGap: &coalesce, Report: true, Env: EnvConfig{ OverlapWordRunGap: &owg, OverlapWordRunReorderWindow: &owrw, BackchannelMaxDuration: &bmd, FillerMaxDuration: &fmd, }, }) if err != nil { t.Fatalf("NewSubprocessRunner() error = %v", err) } dir := t.TempDir() req := MergeRequest{ GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")}, OutputMergedTranscriptPath: filepath.Join(dir, "base.json"), ReportPath: filepath.Join(dir, "seriatim.report.json"), SpeakersPath: filepath.Join(dir, "speakers.yml"), AutocorrectPath: filepath.Join(dir, "autocorrect.yml"), StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"), StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"), } writeSeriatimFile(t, req.InputTranscriptPaths[0], `{"a":1}`) writeSeriatimFile(t, req.InputTranscriptPaths[1], `{"b":1}`) writeSeriatimFile(t, req.SpeakersPath, "speakers: []\n") writeSeriatimFile(t, req.AutocorrectPath, "autocorrect: []\n") res, err := runner.Run(context.Background(), req) if err != nil { t.Fatalf("Run() error = %v", err) } if res.MergedTranscriptPath != req.OutputMergedTranscriptPath { t.Fatalf("MergedTranscriptPath = %q, want %q", res.MergedTranscriptPath, req.OutputMergedTranscriptPath) } if res.ReportPath != req.ReportPath { t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath) } if res.OutputSchema != "seriatim-intermediate" { t.Fatalf("OutputSchema = %q, want seriatim-intermediate", res.OutputSchema) } 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.OutputMergedTranscriptPath) assertJSONFile(t, req.ReportPath) if _, err := os.Stat(req.GeneratedConfigPath); err != nil { t.Fatalf("generated config missing: %v", err) } 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) } rec := readHelperRecord(t, recordPath) wantArgs := []string{ "merge", "--input-file", req.InputTranscriptPaths[0], "--input-file", req.InputTranscriptPaths[1], "--output-file", req.OutputMergedTranscriptPath, "--report-file", req.ReportPath, "--speakers", req.SpeakersPath, "--autocorrect", req.AutocorrectPath, "--output-schema", "seriatim-intermediate", "--coalesce-gap", "3", } if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) } if rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"] != "1" { t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_GAP = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"]) } if rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] != "1" { t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"]) } if rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"] != "2" { t.Fatalf("SERIATIM_BACKCHANNEL_MAX_DURATION = %q, want 2", rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"]) } if rec.Env["SERIATIM_FILLER_MAX_DURATION"] != "1.25" { t.Fatalf("SERIATIM_FILLER_MAX_DURATION = %q, want 1.25", rec.Env["SERIATIM_FILLER_MAX_DURATION"]) } } func TestSubprocessRunnerSubprocessFailure(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 := mergeReqForTest(t, false) _, err := runner.Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want non-nil") } if !strings.Contains(err.Error(), "run seriatim merge") { 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 TestSubprocessRunnerMissingOutputFails(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", "missing_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := mergeReqForTest(t, false) _, err := runner.Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want non-nil") } if !strings.Contains(err.Error(), "validate seriatim merged output") { t.Fatalf("error = %q, want output validation context", err.Error()) } } func TestSubprocessRunnerInvalidOutputJSONFails(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", "invalid_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := mergeReqForTest(t, false) _, err := runner.Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want non-nil") } if !strings.Contains(err.Error(), "parse json") { t.Fatalf("error = %q, want parse json context", err.Error()) } } func TestSubprocessRunnerInvalidReportJSONFails(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", "invalid_report") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), true) req := mergeReqForTest(t, true) _, err := runner.Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want non-nil") } if !strings.Contains(err.Error(), "validate seriatim report output") { t.Fatalf("error = %q, want report validation context", err.Error()) } } func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(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", "trim_success") recordPath := filepath.Join(t.TempDir(), "record.json") t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) wrapper := writeHelperWrapper(t) runner := mustRunner(t, wrapper, false) req := trimReqForTest(t) res, err := runner.Trim(context.Background(), req) if err != nil { t.Fatalf("Trim() error = %v", err) } if res.OutputTrimmedPath != req.OutputTrimmedPath { t.Fatalf("OutputTrimmedPath = %q, want %q", res.OutputTrimmedPath, req.OutputTrimmedPath) } if res.KeepSelector != req.KeepSelector { t.Fatalf("KeepSelector = %q, want %q", res.KeepSelector, req.KeepSelector) } 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.OutputTrimmedPath) 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) } stdoutData, err := os.ReadFile(req.StdoutLogPath) if err != nil { t.Fatalf("read stdout log: %v", err) } if !strings.Contains(string(stdoutData), "seriatim helper trim stdout") { t.Fatalf("stdout log = %q, want trim helper stdout marker", string(stdoutData)) } stderrData, err := os.ReadFile(req.StderrLogPath) if err != nil { t.Fatalf("read stderr log: %v", err) } if !strings.Contains(string(stderrData), "seriatim helper trim stderr") { t.Fatalf("stderr log = %q, want trim helper stderr marker", string(stderrData)) } cfgData, err := os.ReadFile(req.GeneratedConfigPath) if err != nil { t.Fatalf("read generated config: %v", err) } cfgText := string(cfgData) if !strings.Contains(cfgText, "command: trim") { t.Fatalf("generated config = %q, want command: trim", cfgText) } if !strings.Contains(cfgText, "keep_selector: 5-12") { t.Fatalf("generated config = %q, want keep selector", cfgText) } rec := readHelperRecord(t, recordPath) wantArgs := []string{ "trim", "--input-file", req.InputTranscriptPath, "--output-file", req.OutputTrimmedPath, "--keep", req.KeepSelector, } if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) } } func TestSubprocessRunnerTrimSubprocessFailure(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 := trimReqForTest(t) _, err := runner.Trim(context.Background(), req) if err == nil { t.Fatal("Trim() error = nil, want non-nil") } if !strings.Contains(err.Error(), "run seriatim trim") { 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 TestSubprocessRunnerTrimMissingOutputFails(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", "missing_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := trimReqForTest(t) _, err := runner.Trim(context.Background(), req) if err == nil { t.Fatal("Trim() error = nil, want non-nil") } if !strings.Contains(err.Error(), "validate seriatim trimmed output") { t.Fatalf("error = %q, want output validation context", err.Error()) } } func TestSubprocessRunnerTrimInvalidOutputJSONFails(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", "invalid_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := trimReqForTest(t) _, err := runner.Trim(context.Background(), req) if err == nil { t.Fatal("Trim() error = nil, want non-nil") } if !strings.Contains(err.Error(), "parse json") { t.Fatalf("error = %q, want parse json context", err.Error()) } } func TestSubprocessRunnerTrimOutputMissingSegmentsFails(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", "trim_missing_segments") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := trimReqForTest(t) _, err := runner.Trim(context.Background(), req) if err == nil { t.Fatal("Trim() 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 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 TestSubprocessRunnerRenderSuccessInvocationAndProvenance(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", "render_success") recordPath := filepath.Join(t.TempDir(), "record.json") t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) wrapper := writeHelperWrapper(t) runner := mustRunner(t, wrapper, false) req := renderReqForTest(t) res, err := runner.Render(context.Background(), req) if err != nil { t.Fatalf("Render() error = %v", err) } if res.OutputRenderedPath != req.OutputRenderedPath { t.Fatalf("OutputRenderedPath = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath) } if res.Format != req.Format { t.Fatalf("Format = %q, want %q", res.Format, req.Format) } if res.Title != req.Title { t.Fatalf("Title = %q, want %q", res.Title, req.Title) } 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) } if _, err := os.Stat(req.OutputRenderedPath); err != nil { t.Fatalf("rendered output missing: %v", err) } 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) } rec := readHelperRecord(t, recordPath) wantArgs := []string{ "render", "--input-file", req.InputTranscriptPath, "--output-file", req.OutputRenderedPath, "--format", req.Format, "--include-timestamps=true", "--include-segment-ids=true", "--include-metadata=false", "--title", req.Title, } if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) } } func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(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", "render_success") recordPath := filepath.Join(t.TempDir(), "record.json") t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) runner := mustRunner(t, writeHelperWrapper(t), false) req := renderReqForTest(t) req.Title = "" if _, err := runner.Render(context.Background(), req); err != nil { t.Fatalf("Render() error = %v", err) } rec := readHelperRecord(t, recordPath) for i := 0; i < len(rec.Args); i++ { if rec.Args[i] == "--title" { t.Fatalf("args = %#v, did not expect --title", rec.Args) } } } func TestSubprocessRunnerRenderSubprocessFailure(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 := renderReqForTest(t) _, err := runner.Render(context.Background(), req) if err == nil { t.Fatal("Render() error = nil, want non-nil") } if !strings.Contains(err.Error(), "run seriatim render") { t.Fatalf("error = %q, want subprocess context", err.Error()) } } func TestSubprocessRunnerRenderMissingOutputFails(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", "missing_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := renderReqForTest(t) _, err := runner.Render(context.Background(), req) if err == nil { t.Fatal("Render() error = nil, want non-nil") } if !strings.Contains(err.Error(), "validate seriatim rendered output") { t.Fatalf("error = %q, want output validation context", err.Error()) } } func TestSubprocessRunnerRenderEmptyOutputFails(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", "render_empty_output") t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) runner := mustRunner(t, writeHelperWrapper(t), false) req := renderReqForTest(t) _, err := runner.Render(context.Background(), req) if err == nil { t.Fatal("Render() error = nil, want non-nil") } if !strings.Contains(err.Error(), "file is empty") { t.Fatalf("error = %q, want empty-file validation", err.Error()) } } func TestSubprocessRunnerConstructorValidation(t *testing.T) { _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) if err == nil { t.Fatal("expected binary validation error") } _, err = NewSubprocessRunnerFromConfigValues("seriatim", "bad", "seriatim-intermediate", nil, true, EnvConfig{}) if err == nil { t.Fatal("expected timeout parse error") } _, err = NewSubprocessRunner(SubprocessRunnerConfig{ Binary: "seriatim", Timeout: mustParseDuration(t, "1s"), OutputSchema: "bad-schema", }) if err == nil { t.Fatal("expected output schema validation error") } } type helperRecord struct { Args []string `json:"args"` Env map[string]string `json:"env"` } func TestSeriatimSubprocessHelper(t *testing.T) { if os.Getenv("GO_WANT_SERIATIM_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) } cmdArgs := args[start:] outputPath := flagValue(cmdArgs, "--output-file") reportPath := flagValue(cmdArgs, "--report-file") recordPath := os.Getenv("SERIATIM_HELPER_RECORD_PATH") if strings.TrimSpace(recordPath) != "" { rec := helperRecord{ Args: cmdArgs, Env: map[string]string{ "SERIATIM_OVERLAP_WORD_RUN_GAP": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_GAP"), "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"), "SERIATIM_BACKCHANNEL_MAX_DURATION": os.Getenv("SERIATIM_BACKCHANNEL_MAX_DURATION"), "SERIATIM_FILLER_MAX_DURATION": os.Getenv("SERIATIM_FILLER_MAX_DURATION"), }, } data, _ := json.Marshal(rec) _ = os.MkdirAll(filepath.Dir(recordPath), 0o755) _ = os.WriteFile(recordPath, data, 0o644) } mode := os.Getenv("SERIATIM_HELPER_MODE") switch mode { case "success": writeSeriatimHelperFile(outputPath, `{"merged":true}`) if reportPath != "" { writeSeriatimHelperFile(reportPath, `{"report":true}`) } _, _ = os.Stdout.WriteString("seriatim helper success stdout\n") _, _ = os.Stderr.WriteString("seriatim helper success stderr\n") os.Exit(0) case "trim_success": writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) _, _ = 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) case "missing_output": if reportPath != "" { 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) case "render_success": writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n") _, _ = os.Stdout.WriteString("seriatim helper render stdout\n") _, _ = os.Stderr.WriteString("seriatim helper render stderr\n") os.Exit(0) case "render_empty_output": writeSeriatimHelperFile(outputPath, "") os.Exit(0) default: _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) os.Exit(2) } } func writeHelperWrapper(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(), "seriatim-helper-wrapper.sh") content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestSeriatimSubprocessHelper -- \"$@\"\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } return path } func mergeReqForTest(t *testing.T, withReport bool) MergeRequest { t.Helper() dir := t.TempDir() in1 := filepath.Join(dir, "a.json") in2 := filepath.Join(dir, "b.json") writeSeriatimFile(t, in1, `{"a":1}`) writeSeriatimFile(t, in2, `{"b":1}`) req := MergeRequest{ GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), InputTranscriptPaths: []string{in1, in2}, OutputMergedTranscriptPath: filepath.Join(dir, "base.json"), StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"), StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"), } if withReport { req.ReportPath = filepath.Join(dir, "seriatim.report.json") } return req } func trimReqForTest(t *testing.T) TrimRequest { t.Helper() dir := t.TempDir() input := filepath.Join(dir, "polished.json") writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`) return TrimRequest{ InputTranscriptPath: input, OutputTrimmedPath: filepath.Join(dir, "final.trimmed.json"), KeepSelector: "5-12", GeneratedConfigPath: filepath.Join(dir, "seriatim.trim.generated.yml"), StdoutLogPath: filepath.Join(dir, "seriatim.trim.stdout.log"), StderrLogPath: filepath.Join(dir, "seriatim.trim.stderr.log"), } } func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest { t.Helper() dir := t.TempDir() input := filepath.Join(dir, "polished.json") writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`) req := NormalizeRequest{ InputTranscriptPath: input, OutputNormalizedPath: filepath.Join(dir, "final.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 renderReqForTest(t *testing.T) RenderRequest { t.Helper() dir := t.TempDir() input := filepath.Join(dir, "final.trimmed.json") writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`) return RenderRequest{ InputTranscriptPath: input, OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"), Format: "markdown", Title: "Session 42", IncludeTimestamps: true, IncludeSegmentIDs: true, IncludeMetadata: false, GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"), StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"), StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"), } } func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner { t.Helper() coalesce := 3.0 r, err := NewSubprocessRunner(SubprocessRunnerConfig{ Binary: binary, Timeout: mustParseDuration(t, "2s"), OutputSchema: "seriatim-intermediate", CoalesceGap: &coalesce, Report: report, }) if err != nil { t.Fatalf("NewSubprocessRunner() error = %v", err) } return r } func mustParseDuration(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 writeSeriatimFile(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 writeSeriatimHelperFile(path, contents string) { if strings.TrimSpace(path) == "" { return } _ = os.MkdirAll(filepath.Dir(path), 0o755) _ = os.WriteFile(path, []byte(contents), 0o644) } func assertJSONFile(t *testing.T, path string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q): %v", path, err) } var v any if err := json.Unmarshal(data, &v); err != nil { t.Fatalf("json unmarshal %q: %v", path, err) } } func readHelperRecord(t *testing.T, path string) helperRecord { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q): %v", path, err) } var rec helperRecord if err := json.Unmarshal(data, &rec); err != nil { t.Fatalf("json unmarshal helper record: %v", err) } return rec }