package app import ( "bytes" "context" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestPlanDoesNotCreateWorkdir(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath} if err := Plan(context.Background(), args, &out); err != nil { t.Fatalf("first Plan() error = %v", err) } got := out.String() if !strings.Contains(got, "narratio session plan: read-only workdir at") { t.Fatalf("first output = %q, want read-only workdir", got) } for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} { if !strings.Contains(got, name+": run") { t.Fatalf("first output = %q, missing stage %q", got, name) } } if !strings.Contains(got, "totals: run=11 skip=0") { t.Fatalf("first output = %q, want totals", got) } sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) { t.Fatalf("workdir stat error = %v, want absent", err) } out.Reset() if err := Plan(context.Background(), args, &out); err != nil { t.Fatalf("second Plan() error = %v", err) } if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") { t.Fatalf("second output = %q, want read-only workdir", out.String()) } } func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") 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) m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil) if err := store.Save(context.Background(), manifestPath, m); err != nil { t.Fatalf("save manifest: %v", err) } var out bytes.Buffer if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil { t.Fatalf("Plan() error = %v", err) } got := out.String() if !strings.Contains(got, "prepare: skip") || !strings.Contains(got, "transcribe: skip") { t.Fatalf("output = %q, want prepare/transcribe skipped", got) } if !strings.Contains(got, "trim: run") { t.Fatalf("output = %q, want trim run", got) } if !strings.Contains(got, "totals: run=9 skip=2") { t.Fatalf("output = %q, want totals run=9 skip=2", got) } } func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) { workspaceRoot := t.TempDir() configDir := t.TempDir() pipelinePath := filepath.Join(configDir, "pipeline.yml") campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: root: ` + workspaceRoot + ` storage: backend: local secrets: env_dir: ./missing-secrets whisperx: transcribe_url: https://example.com/transcribe seriatim: binary: seriatim audita: binary: audita notification: mode: noop ` sessionYAML := `session_id: 2026-05-03 campaign: sample-campaign inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } var out bytes.Buffer err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err) } } func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{}) if err != nil { t.Fatalf("load config: %v", err) } analyze, err := stage.Select("analyze") if err != nil { t.Fatal(err) } fake := &scriptorium.FakeRunner{} first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{ Env: &Env{Scriptorium: fake}, }) if err != nil { t.Fatalf("initial analyze: %v", err) } if len(fake.RunRequests) != 2 { t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests)) } store := &manifest.LocalStore{} m, err := store.Load(context.Background(), first.ManifestPath) if err != nil { t.Fatal(err) } for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} { m.MarkStageSucceeded(name, time.Now().UTC(), nil) } m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration") m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled") if err := store.Save(context.Background(), first.ManifestPath, m); err != nil { t.Fatal(err) } manifestBefore, err := os.ReadFile(first.ManifestPath) if err != nil { t.Fatal(err) } filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath)) marker := filepath.Join(t.TempDir(), "adapter-invoked") binaryDir := t.TempDir() binary := filepath.Join(binaryDir, "scriptorium") if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer err = Plan(context.Background(), []string{ "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--from", "render", "--through", "analyze", }, &out) if err != nil { t.Fatalf("Plan() error = %v", err) } got := out.String() for _, want := range []string{ "render: run", "extract: run", "analyze: run", " targets: player_handout, session_recap", " prerequisites: none", " execute: none", "player_handout(target:current)", "session_recap(target:current)", } { if !strings.Contains(got, want) { t.Fatalf("plan output = %q, want %q", got, want) } } manifestAfter, err := os.ReadFile(first.ManifestPath) if err != nil { t.Fatal(err) } if !bytes.Equal(manifestBefore, manifestAfter) { t.Fatal("plan modified the session manifest") } if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) { t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore) } if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { t.Fatalf("adapter marker stat = %v, want absent", err) } fake.RunRequests = nil actual, err := executeStages(context.Background(), cfg, []stage.Stage{ resultStage{name: "render", result: &stage.StageResult{}}, resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}}, analyze, }, RunOptions{ Env: &Env{Scriptorium: fake}, }) if err != nil { t.Fatalf("actual analyze: %v", err) } if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) || !reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 { t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests)) } } func planFixtureFiles(t *testing.T, root string) []string { t.Helper() var files []string err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } relative, err := filepath.Rel(root, path) if err != nil { return err } files = append(files, relative+":"+entry.Type().String()) return nil }) if err != nil { t.Fatal(err) } return files }