package app import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"run-stage", "extract", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &stdout, &stderr, ) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stages "analyze" and "publish"`) { t.Fatalf("stderr = %q, want stage-gating error", stderr.String()) } } func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var capturedStages []string var capturedArtifacts []string origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { for _, s := range stages { capturedStages = append(capturedStages, s.Name()) } capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"publish"}}, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{ "run-stage", "publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", }, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if len(capturedStages) != 1 || capturedStages[0] != "publish" { t.Fatalf("captured stages = %#v, want [publish]", capturedStages) } if strings.Join(capturedArtifacts, ",") != "session_recap" { t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts) } } func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `run: --artifacts includes unknown artifact "unknown_artifact"`) { t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String()) } } func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) seed.MarkStageSucceeded("analyze", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) if err := store.Save(context.Background(), manifestPath, seed); err != nil { t.Fatalf("save manifest: %v", err) } var out bytes.Buffer err := RunStage( context.Background(), []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"}, &out, ) if err != nil { t.Fatalf("RunStage() error = %v", err) } if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") { t.Fatalf("output = %q, want analyze skip without force", out.String()) } } func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled") if err := store.Save(context.Background(), manifestPath, seed); err != nil { t.Fatalf("save manifest: %v", err) } var out bytes.Buffer err := Run( context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &out, ) if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(out.String(), "executed=1 skipped=11") { t.Fatalf("output = %q, want all stages skipped", out.String()) } } func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var capturedStages []string var capturedForce bool origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { for _, s := range stages { capturedStages = append(capturedStages, s.Name()) } capturedForce = opts.Force return &RunSummary{ ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"), Executed: []string{"analyze"}, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if len(capturedStages) != 1 || capturedStages[0] != "analyze" { t.Fatalf("captured stages = %#v, want [analyze]", capturedStages) } if !capturedForce { t.Fatal("captured force = false, want true") } if !strings.Contains(stdout.String(), "narratio analyze: executed=1 skipped=0 force=true; manifest=") { t.Fatalf("stdout = %q, want analyze summary", stdout.String()) } } func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var capturedArtifacts []string origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) { capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{ "analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "player_handout,session_recap", }, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if strings.Join(capturedArtifacts, ",") != "player_handout,session_recap" { t.Fatalf("captured artifacts = %#v, want sorted selected artifacts", capturedArtifacts) } } func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `analyze: --artifacts includes unknown artifact "unknown_artifact"`) { t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String()) } } func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) { cases := []struct { name string args []string want string }{ {name: "extra positional", args: []string{"analyze", "2026-05-03", "extra"}, want: "analyze: unexpected positional arguments"}, {name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"}, } 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.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), tc.want) { t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want) } }) } } func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") { t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String()) } } func TestExecutePublishForceRunsPublish(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var capturedStages []string var capturedForce bool var capturedArtifacts []string origExecuteStagesFn := executeStagesFn t.Cleanup(func() { executeStagesFn = origExecuteStagesFn }) executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { for _, s := range stages { capturedStages = append(capturedStages, s.Name()) } capturedForce = opts.Force capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) return &RunSummary{ ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"), Executed: []string{"publish"}, }, nil } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &stdout, &stderr, ) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if len(capturedStages) != 1 || capturedStages[0] != "publish" { t.Fatalf("captured stages = %#v, want [publish]", capturedStages) } if !capturedForce { t.Fatal("captured force = false, want true") } if strings.Join(capturedArtifacts, ",") != "session_recap" { t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts) } if !strings.Contains(stdout.String(), "narratio publish: executed=1 skipped=0 force=true; manifest=") { t.Fatalf("stdout = %q, want publish summary", stdout.String()) } } func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) { cases := []struct { name string args []string want string }{ {name: "extra positional", args: []string{"publish", "2026-05-03", "extra"}, want: "publish: unexpected positional arguments"}, {name: "force flag", args: []string{"publish", "--force"}, want: "publish: invalid flags: flag provided but not defined: -force"}, } 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.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), tc.want) { t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want) } }) } } func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `publish: --artifacts includes unknown artifact "unknown_artifact"`) { t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String()) } } func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") { t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String()) } } func TestExecuteUsageIncludesAnalyzeAndPublish(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(nil, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "analyze") { t.Fatalf("stderr = %q, want usage to include analyze", stderr.String()) } if !strings.Contains(stderr.String(), "publish") { t.Fatalf("stderr = %q, want usage to include publish", stderr.String()) } } func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("open pipeline config for append: %v", err) } defer f.Close() extra := ` scriptorium: binary: scriptorium artifacts: session_recap: enabled: true prompt_id: dnd.session_recap output_path: artifacts/session_recap.md player_handout: enabled: true prompt_id: dnd.player_handout output_path: artifacts/player_handout.md depends_on: - session_recap inputs: recap: source: narratio.artifact.session_recap required: true ` if _, err := f.WriteString(extra); err != nil { t.Fatalf("append scriptorium config: %v", err) } return pipelinePath, campaignPath, sessionPath }