All checks were successful
ci/woodpecker/tag/release Pipeline was successful
464 lines
15 KiB
Go
464 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
)
|
|
|
|
func TestExecuteValidCommands(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"source":"commands-test","segments":[{"speaker":"alice"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
|
manifestPath := writeManifestPathForExecute(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
wantOut string
|
|
}{
|
|
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
|
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
|
|
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
|
|
{name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
|
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute(tc.args, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0", code)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), tc.wantOut) {
|
|
t.Fatalf("stdout = %q, want to contain %q", stdout.String(), tc.wantOut)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "run missing flags", args: []string{"run"}, want: "run: --session is required"},
|
|
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --session is required"},
|
|
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
|
|
{name: "resume missing flags", args: []string{"resume"}, want: "resume: --session is required"},
|
|
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"},
|
|
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --session is required"},
|
|
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute(tc.args, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatalf("exit code = 0, want non-zero")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), tc.want) {
|
|
t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunStageUnknownFails(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "unknown"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "unknown stage") {
|
|
t.Fatalf("stderr = %q, want unknown stage error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
|
workRoot := filepath.Join(workspaceRoot, "work", "2026-05-03")
|
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "normalize"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "stage=normalize executed=1 skipped=0") {
|
|
t.Fatalf("stdout = %q, want normalize stage execution", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
var serverCalls int
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
serverCalls++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"source":"run-stage-test","segments":[{"speaker":"alice"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "prepare"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
|
|
code = Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if serverCalls == 0 {
|
|
t.Fatal("expected whisperx server to be called at least once")
|
|
}
|
|
|
|
outPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "transcripts", "raw", "alice.json")
|
|
data, err := os.ReadFile(outPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", outPath, err)
|
|
}
|
|
got := strings.TrimSpace(string(data))
|
|
if got == `{"schema":"speaker_transcript.v1","segments":[]}` {
|
|
t.Fatalf("got noop transcript output: %q", got)
|
|
}
|
|
if !strings.Contains(got, `"source":"run-stage-test"`) {
|
|
t.Fatalf("output = %q, want run-stage server json", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"source":"default-config-test","segments":[{"speaker":"alice"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
|
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
|
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
|
defer func() {
|
|
config.DefaultPipelineConfigSearchPaths = originalDefaults
|
|
}()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"run", "--session", sessionPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
|
|
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteInvalidCommand(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute([]string{"bogus"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatalf("exit code = 0, want non-zero")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
}
|
|
out := stderr.String()
|
|
if !strings.Contains(out, "unknown command") {
|
|
t.Fatalf("stderr = %q, want unknown command message", out)
|
|
}
|
|
if !strings.Contains(out, "Usage: narratio") {
|
|
t.Fatalf("stderr = %q, want usage message", out)
|
|
}
|
|
}
|
|
|
|
func TestExecuteMissingCommand(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
code := Execute(nil, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatalf("exit code = 0, want non-zero")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "Usage: narratio") {
|
|
t.Fatalf("stderr = %q, want usage message", stderr.String())
|
|
}
|
|
}
|
|
|
|
func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
|
sessionPath := filepath.Join(dir, "session.yml")
|
|
url := "https://example.com/transcribe"
|
|
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
|
|
url = transcribeURL[0]
|
|
}
|
|
seriatimBinary := writeSeriatimAppTestWrapper(t)
|
|
auditaBinary := writeAuditaAppTestWrapper(t)
|
|
t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1")
|
|
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
|
|
t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key")
|
|
|
|
pipelineYAML := `workspace:
|
|
root: ` + workspaceRoot + `
|
|
storage:
|
|
backend: s3
|
|
whisperx:
|
|
transcribe_url: ` + url + `
|
|
timeout: 2s
|
|
retries: 0
|
|
retry_delay: 1ms
|
|
concurrency: 1
|
|
seriatim:
|
|
binary: ` + seriatimBinary + `
|
|
timeout: 10m
|
|
output_schema: seriatim-intermediate
|
|
coalesce_gap: 3.0
|
|
report: true
|
|
audita:
|
|
binary: ` + auditaBinary + `
|
|
analyzer:
|
|
timeout: 20m
|
|
artifacts:
|
|
output_dir: artifacts
|
|
notification:
|
|
timeout: 10s
|
|
`
|
|
|
|
sessionYAML := `session_id: 2026-05-03
|
|
inputs:
|
|
audio_dir: ./audio
|
|
speakers_file: ./speakers.yml
|
|
autocorrect_file: ./autocorrect.yml
|
|
glossary_file: ./glossary.yml
|
|
`
|
|
|
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline config: %v", err)
|
|
}
|
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
|
t.Fatalf("write session config: %v", err)
|
|
}
|
|
|
|
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
|
|
|
return pipelinePath, sessionPath
|
|
}
|
|
|
|
func writeManifestPathForExecute(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
store := &manifest.LocalStore{}
|
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
|
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
|
|
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
|
if err := store.Save(context.Background(), path, m); err != nil {
|
|
t.Fatalf("save manifest: %v", err)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
func mustWriteTestFile(t *testing.T, path, contents string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir %q: %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
t.Fatalf("write %q: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func writeSeriatimAppTestWrapper(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=TestSeriatimAppHelper -- \"$@\"\n"
|
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestSeriatimAppHelper(t *testing.T) {
|
|
if os.Getenv("GO_WANT_APP_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)
|
|
}
|
|
mergeArgs := args[start:]
|
|
|
|
outputPath := appSeriatimFlagValue(mergeArgs, "--output-file")
|
|
reportPath := appSeriatimFlagValue(mergeArgs, "--report-file")
|
|
if strings.TrimSpace(outputPath) == "" {
|
|
_, _ = os.Stderr.WriteString("missing --output-file\n")
|
|
os.Exit(2)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if err := os.WriteFile(outputPath, []byte(`{"schema":"seriatim-intermediate","segments":[]}`), 0o644); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if strings.TrimSpace(reportPath) != "" {
|
|
if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
_, _ = os.Stdout.WriteString("seriatim helper stdout\n")
|
|
_, _ = os.Stderr.WriteString("seriatim helper stderr\n")
|
|
os.Exit(0)
|
|
}
|
|
|
|
func writeAuditaAppTestWrapper(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(), "audita-helper-wrapper.sh")
|
|
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestAuditaAppHelper -- \"$@\"\n"
|
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestAuditaAppHelper(t *testing.T) {
|
|
if os.Getenv("GO_WANT_APP_AUDITA_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)
|
|
}
|
|
processArgs := args[start:]
|
|
|
|
outputPath := appSeriatimFlagValue(processArgs, "--output")
|
|
reportPath := appSeriatimFlagValue(processArgs, "--report-json")
|
|
workDir := appSeriatimFlagValue(processArgs, "--work-dir")
|
|
if strings.TrimSpace(outputPath) == "" {
|
|
_, _ = os.Stderr.WriteString("missing --output\n")
|
|
os.Exit(2)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if err := os.WriteFile(outputPath, []byte(`{"schema":"audita.processed.v1","segments":[]}`), 0o644); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if strings.TrimSpace(reportPath) != "" {
|
|
if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
if strings.TrimSpace(workDir) != "" {
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir work dir: %v\n", err))
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
_, _ = os.Stdout.WriteString("audita helper stdout\n")
|
|
_, _ = os.Stderr.WriteString("audita helper stderr\n")
|
|
os.Exit(0)
|
|
}
|
|
|
|
func appSeriatimFlagValue(args []string, name string) string {
|
|
for i := 0; i < len(args)-1; i++ {
|
|
if args[i] == name {
|
|
return args[i+1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|