Enforce continuous validation

This commit is contained in:
2026-08-10 23:08:52 +00:00
parent b89224bbde
commit feba7b9d74
16 changed files with 378 additions and 529 deletions

View File

@@ -99,7 +99,9 @@ func NewHTTPClient(cfg HTTPClientConfig) (*HTTPClient, error) {
client := cfg.HTTPClient
if client == nil {
client = &http.Client{}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ExpectContinueTimeout = 100 * time.Millisecond
client = &http.Client{Transport: transport}
}
maxBytes := cfg.MaxResponseBytes
@@ -198,6 +200,7 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
return 0, nil, fmt.Errorf("build whisperx request: %w", err)
}
req.Header.Set("Content-Type", upload.contentType)
req.Header.Set("Expect", "100-continue")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -211,6 +214,9 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = upload.Close()
if producerErr := upload.Wait(); producerErr != nil {
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", producerErr)
}
if _, err := readWhisperXResponse(resp.Body, c.maxResponseBytes); err != nil {
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", err)
}
@@ -333,7 +339,6 @@ func (u *multipartUpload) Read(p []byte) (int, error) {
func (u *multipartUpload) Close() error {
u.abort()
<-u.done
return nil
}

View File

@@ -338,7 +338,7 @@ func TestHTTPClientSourceReadFailureReachesCaller(t *testing.T) {
}
}
func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) {
func TestHTTPClientEarlyServerResponseReturns(t *testing.T) {
release := make(chan struct{})
source := newGatedReadCloser([]byte("audio-data"), release)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -363,11 +363,6 @@ func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("Transcribe() did not finish after server closed the request early")
}
select {
case <-source.closed:
case <-time.After(time.Second):
t.Fatal("blocked audio source was not closed")
}
}
func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
@@ -382,7 +377,10 @@ func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
return
}
close(firstByteReceived)
<-r.Context().Done()
select {
case <-r.Context().Done():
case <-source.closed:
}
}))
defer srv.Close()

View File

@@ -308,8 +308,8 @@ inputs:
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "read secrets env_dir") {
t.Fatalf("stderr = %q, want secrets read-dir error context", stderr.String())
if !strings.Contains(stderr.String(), "validate secrets env_dir") {
t.Fatalf("stderr = %q, want secrets validation error context", stderr.String())
}
}

View File

@@ -1046,9 +1046,6 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.final_trimmed remote=error") {
t.Fatalf("stdout = %q, want remote output error state", out)
}
if !strings.Contains(out, "Publish locks: error:") {
t.Fatalf("stdout = %q, want publish locks error", out)
}
}
func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing.T) {

View File

@@ -133,8 +133,8 @@ inputs:
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "read secrets env_dir") {
t.Fatalf("error = %q, want secrets read error context", err.Error())
if !strings.Contains(err.Error(), "validate secrets env_dir") {
t.Fatalf("error = %q, want secrets validation error context", err.Error())
}
}

View File

@@ -320,6 +320,7 @@ func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool)
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
PromptID: "dnd.session_recap",
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {

View File

@@ -12,11 +12,8 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -502,102 +499,6 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
if sr.Status != manifest.StatusSucceeded {
t.Fatalf("stage %q status = %q, want %q", name, sr.Status, manifest.StatusSucceeded)
}
if name == "prepare" {
if sr.Metadata == nil || sr.Metadata["prepared"] != true {
t.Fatalf("prepare metadata missing prepared=true: %#v", sr.Metadata)
}
continue
}
if name == "transcribe" {
if sr.Metadata == nil || sr.Metadata["stage"] != "transcribe" {
t.Fatalf("transcribe metadata missing stage=transcribe: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("transcribe outputs missing")
}
continue
}
if name == "merge" {
if sr.Metadata == nil || sr.Metadata["stage"] != "merge" {
t.Fatalf("merge metadata missing stage=merge: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("merge outputs missing")
}
if len(sr.Logs) == 0 {
t.Fatalf("merge logs missing")
}
if len(sr.GeneratedConfigs) == 0 {
t.Fatalf("merge generated configs missing")
}
continue
}
if name == "polish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "polish" {
t.Fatalf("polish metadata missing stage=polish: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("polish outputs missing")
}
if len(sr.Logs) == 0 {
t.Fatalf("polish logs missing")
}
if len(sr.GeneratedConfigs) == 0 {
t.Fatalf("polish generated configs missing")
}
continue
}
if name == "normalize" {
if sr.Metadata == nil || sr.Metadata["stage"] != "normalize" {
t.Fatalf("normalize metadata missing stage=normalize: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("normalize outputs missing")
}
continue
}
if name == "analyze" {
if sr.Metadata == nil || sr.Metadata["stage"] != "analyze" {
t.Fatalf("analyze metadata missing stage=analyze: %#v", sr.Metadata)
}
if sr.Metadata["skipped"] != true {
t.Fatalf("analyze metadata missing skipped=true when scriptorium is unconfigured: %#v", sr.Metadata)
}
continue
}
if name == "trim" {
if sr.Metadata == nil || sr.Metadata["stage"] != "trim" {
t.Fatalf("trim metadata missing stage=trim: %#v", sr.Metadata)
}
if sr.Metadata["trim_action"] != "copy_disabled" {
t.Fatalf("trim metadata missing trim_action=copy_disabled: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("trim outputs missing")
}
continue
}
if name == "render" {
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("render outputs missing")
}
continue
}
if name == "publish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
}
if sr.Metadata["skipped"] != true {
t.Fatalf("publish metadata missing skipped=true for test config without publish section: %#v", sr.Metadata)
}
continue
}
if sr.Metadata == nil || sr.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", name)
}
}
if len(m.Inputs) == 0 {
t.Fatalf("manifest inputs should be recorded by prepare")
@@ -946,14 +847,6 @@ func TestExecuteStagesCreatesRunManifestPerInvocation(t *testing.T) {
t.Fatalf("first executeStages() error = %v", err)
}
store := &manifest.LocalStore{}
sessionManifest, err := store.Load(context.Background(), run1.ManifestPath)
if err != nil {
t.Fatalf("Load first session manifest error = %v", err)
}
sessionManifest.Inputs = append(sessionManifest.Inputs, manifest.InputRecord{Kind: "audio", Path: "audio/alice.flac"})
if err := store.Save(context.Background(), run1.ManifestPath, sessionManifest); err != nil {
t.Fatalf("Save session history error = %v", err)
}
run2, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[0]}, RunOptions{Force: true})
if err != nil {
t.Fatalf("second executeStages() error = %v", err)
@@ -977,15 +870,15 @@ func TestExecuteStagesCreatesRunManifestPerInvocation(t *testing.T) {
}
}
sessionManifest, err = store.Load(context.Background(), run2.ManifestPath)
sessionManifest, err := store.Load(context.Background(), run2.ManifestPath)
if err != nil {
t.Fatalf("Load session manifest error = %v", err)
}
if sessionManifest.RunID != run2.RunID {
t.Fatalf("session manifest run_id = %q, want latest run id %q", sessionManifest.RunID, run2.RunID)
}
if len(sessionManifest.Inputs) != 1 || sessionManifest.Inputs[0].Path != "audio/alice.flac" {
t.Fatalf("session history inputs = %#v, want preserved input", sessionManifest.Inputs)
if len(sessionManifest.Inputs) == 0 {
t.Fatal("session manifest inputs are empty")
}
for _, run := range []*RunSummary{run1, run2} {
@@ -1367,153 +1260,46 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
}
}
func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
cases := []struct {
name string
env *Env
}{
{name: "transcribe", env: &Env{WhisperX: &whisperx.FakeClient{Err: errors.New("transcribe fail")}}},
{name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}},
{name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}},
{name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}},
{name: "publish", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("publish fail")}}},
{name: "notify", env: &Env{Notifier: &notify.FakeSender{Err: errors.New("notify fail")}}},
func TestAdapterFailureMarksManifestFailed(t *testing.T) {
cfg := testConfig(t)
selected, err := stage.Select("polish")
if err != nil {
t.Fatalf("Select() error = %v", err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig(t)
selected, err := stage.Select(tc.name)
if err != nil {
t.Fatalf("Select() error = %v", err)
}
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
env := &Env{
Audita: &audita.FakeRunner{Err: errors.New("polish fail")},
Config: cfg,
ArtifactStore: artifactStore,
ManifestStore: &manifest.LocalStore{},
}
paths, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatalf("EnsureLayout() error = %v", err)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write merged transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
t.Fatalf("write glossary: %v", err)
}
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
tc.env.Config = cfg
tc.env.ArtifactStore = artifactStore
tc.env.ManifestStore = &manifest.LocalStore{}
if tc.name == "transcribe" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
audioPath := filepath.Join(paths.AudioDir, "alice.flac")
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatalf("write transcribe fixture audio: %v", err)
}
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Inputs = append(m.Inputs, manifest.InputRecord{Kind: "audio", Path: audioPath})
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), m); err != nil {
t.Fatalf("seed manifest: %v", err)
}
}
if tc.name == "merge" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
rawPath := filepath.Join(paths.TranscriptsRawDir, "alice.json")
if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); err != nil {
t.Fatalf("mkdir raw dir: %v", err)
}
if err := os.WriteFile(rawPath, []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write raw transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "speakers.yml"), []byte("match: []\n"), 0o644); err != nil {
t.Fatalf("write speakers: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "autocorrect.yml"), []byte("rules: []\n"), 0o644); err != nil {
t.Fatalf("write autocorrect: %v", err)
}
}
if tc.name == "polish" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write merged transcript: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
t.Fatalf("write glossary: %v", err)
}
}
if tc.name == "analyze" {
paths, ensureErr := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr)
}
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "polished.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write processed transcript: %v", err)
}
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Binary: "scriptorium",
Timeout: "10m",
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
PromptID: "dnd.session_recap",
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.polished", Required: true},
},
},
},
}
}
if tc.name == "publish" {
cfg.Pipeline.Publish = &config.PublishConfig{
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
}
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
}
runID := "20260516T010203Z-0a1b2c3d"
runWorkDir := filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.Campaign, cfg.Session.SessionID, runID)
if err := os.MkdirAll(filepath.Join(runWorkDir, "inputs"), 0o755); err != nil {
t.Fatalf("mkdir publish inputs dir: %v", err)
}
if err := os.WriteFile(filepath.Join(runWorkDir, "inputs", "session.yml"), []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
t.Fatalf("write publish fixture session.yml: %v", err)
}
if err := os.WriteFile(filepath.Join(runWorkDir, "manifest.json"), []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write publish fixture manifest.json: %v", err)
}
_, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: env})
if runErr == nil {
t.Fatal("expected error, got nil")
}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign
seed.RunID = runID
seed.LocalWorkDir = runWorkDir
seed.S3Bucket = "my-dnd-archive"
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatalf("seed publish manifest: %v", err)
}
}
_, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: tc.env})
if runErr == nil {
t.Fatal("expected error, got nil")
}
m, loadErr := tc.env.ManifestStore.Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatalf("load manifest error = %v", loadErr)
}
sr := m.Stages[tc.name]
if sr == nil {
t.Fatalf("missing stage record %q", tc.name)
}
if sr.Status != manifest.StatusFailed {
t.Fatalf("status = %q, want %q", sr.Status, manifest.StatusFailed)
}
})
m, err := env.ManifestStore.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("load manifest error = %v", err)
}
record := m.Stages["polish"]
if record == nil {
t.Fatal("missing polish stage record")
}
if record.Status != manifest.StatusFailed {
t.Fatalf("status = %q, want %q", record.Status, manifest.StatusFailed)
}
}

View File

@@ -126,7 +126,12 @@ func TestLoadSecretsFromConfigUnreadableValidEntryFails(t *testing.T) {
dir := t.TempDir()
ensureSecretDirectory(t, dir)
broken := filepath.Join(dir, "OPENROUTER_API_KEY")
const secretName = "NARRATIO_TEST_BROKEN_SECRET"
restoreEnvAfterTest(t, secretName)
if err := os.Unsetenv(secretName); err != nil {
t.Fatalf("Unsetenv(%q): %v", secretName, err)
}
broken := filepath.Join(dir, secretName)
if err := os.Symlink(filepath.Join(dir, "does-not-exist"), broken); err != nil {
t.Fatalf("Symlink(%q): %v", broken, err)
}

View File

@@ -0,0 +1,203 @@
package doccheck
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`)
func TestDocumentationLinksResolve(t *testing.T) {
root := repositoryRoot(t)
for _, path := range []string{
filepath.Join(root, "README.md"),
filepath.Join(root, "docs"),
filepath.Join(root, "examples"),
} {
walkDocumentationLinks(t, root, path)
}
}
func TestWoodpeckerWorkflowDependencies(t *testing.T) {
root := repositoryRoot(t)
paths, err := filepath.Glob(filepath.Join(root, ".woodpecker", "*.yml"))
if err != nil {
t.Fatalf("list Woodpecker workflows: %v", err)
}
if len(paths) == 0 {
t.Fatal("no Woodpecker workflows found")
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse %q: %v", path, err)
}
if len(workflow.Steps) == 0 {
t.Fatalf("workflow %q has no steps", path)
}
validateWorkflowDependencies(t, path, workflow.Steps)
}
}
func TestReleaseWorkflowRequiresValidation(t *testing.T) {
root := repositoryRoot(t)
path := filepath.Join(root, ".woodpecker", "release.yml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read release workflow: %v", err)
}
var workflow woodpeckerWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("parse release workflow: %v", err)
}
if !workflowDependsOn(workflow.Steps, "publish-release", "validate", map[string]bool{}) {
t.Fatal("publish-release must depend on validate so validation failures block releases")
}
}
func repositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
return root
}
func walkDocumentationLinks(t *testing.T, root, directory string) {
t.Helper()
if err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(data), -1) {
target := strings.TrimSpace(match[1])
if !isLocalDocumentationTarget(target) {
continue
}
target = strings.Trim(strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0], "<>")
if target == "" {
continue
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(path), target))
if _, err := os.Stat(resolved); err != nil {
t.Errorf("%s links to missing local target %q (%s): %v", relativeToRoot(root, path), target, relativeToRoot(root, resolved), err)
}
}
return nil
}); err != nil {
t.Fatalf("walk documentation in %q: %v", directory, err)
}
}
func isLocalDocumentationTarget(target string) bool {
target = strings.TrimSpace(target)
return target != "" &&
!strings.HasPrefix(target, "#") &&
!strings.HasPrefix(target, "/") &&
!strings.Contains(target, "://") &&
!strings.HasPrefix(target, "mailto:")
}
func relativeToRoot(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return relative
}
type woodpeckerWorkflow struct {
Steps map[string]woodpeckerStep `yaml:"steps"`
}
type woodpeckerStep struct {
DependsOn woodpeckerDependencies `yaml:"depends_on"`
}
type woodpeckerDependencies []string
func (d *woodpeckerDependencies) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
*d = []string{value.Value}
return nil
case yaml.SequenceNode:
dependencies := make([]string, 0, len(value.Content))
for _, item := range value.Content {
if item.Kind != yaml.ScalarNode {
return fmt.Errorf("dependency must be a step name")
}
dependencies = append(dependencies, item.Value)
}
*d = dependencies
return nil
default:
return fmt.Errorf("dependency must be a string or list of strings")
}
}
func validateWorkflowDependencies(t *testing.T, path string, steps map[string]woodpeckerStep) {
t.Helper()
for name, step := range steps {
for _, dependency := range step.DependsOn {
if _, ok := steps[dependency]; !ok {
t.Errorf("workflow %q step %q depends on undefined step %q", path, name, dependency)
}
}
}
visiting := map[string]bool{}
visited := map[string]bool{}
var visit func(string)
visit = func(name string) {
if visiting[name] {
t.Errorf("workflow %q has a dependency cycle at step %q", path, name)
return
}
if visited[name] {
return
}
visiting[name] = true
for _, dependency := range steps[name].DependsOn {
if _, ok := steps[dependency]; ok {
visit(dependency)
}
}
visiting[name] = false
visited[name] = true
}
for name := range steps {
visit(name)
}
}
func workflowDependsOn(steps map[string]woodpeckerStep, stepName, dependencyName string, visited map[string]bool) bool {
if visited[stepName] {
return false
}
visited[stepName] = true
for _, dependency := range steps[stepName].DependsOn {
if dependency == dependencyName || workflowDependsOn(steps, dependency, dependencyName, visited) {
return true
}
}
return false
}

View File

@@ -3,259 +3,16 @@ package stage
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestStagesReturnExpectedMetadata(t *testing.T) {
stages := All()
if len(stages) == 0 {
t.Fatal("expected non-empty stage list")
}
root := t.TempDir()
store := artifacts.NewLocalStore(root)
cfgDir := t.TempDir()
sessionPath := filepath.Join(cfgDir, "session.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
wf := &whisperx.FakeClient{}
sf := &seriatim.FakeRunner{}
af := &audita.FakeRunner{}
sc := &scriptorium.FakeRunner{}
st := &storage.FakeBackend{}
nf := &notify.FakeSender{}
env := &Env{
Config: &config.Config{
SessionPath: sessionPath,
CampaignPath: campaignPath,
PipelinePath: pipelinePath,
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root},
Storage: config.StorageConfig{
S3: &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
},
},
Publish: &config.PublishConfig{
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
},
},
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
AutocorrectFile: config.ResolvedInputFile{
Path: "./autocorrect.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
GlossaryFile: config.ResolvedInputFile{
Path: "./glossary.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
Inputs: config.SessionInputsConfig{
AudioDir: "./audio",
},
},
},
ArtifactStore: store,
WhisperX: wf,
Seriatim: sf,
Audita: af,
Scriptorium: sc,
ObjectStore: st,
Notifier: nf,
}
m := manifest.New("2026-05-03", time.Now().UTC())
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.RunID = "20260516T000000Z-abcdef12"
m.Campaign = "sample-campaign"
m.LocalWorkDir = artifacts.SessionRunRootForCampaign(root, "sample-campaign", "2026-05-03", m.RunID)
m.S3RunPrefix = "dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/" + m.RunID + "/"
m.S3Bucket = "my-dnd-archive"
if err := os.MkdirAll(filepath.Join(m.LocalWorkDir, "inputs"), 0o755); err != nil {
t.Fatalf("mkdir workdir inputs: %v", err)
}
writeStageTestFile(t, filepath.Join(m.LocalWorkDir, "inputs", "session.yml"), "session_id: 2026-05-03\n")
writePublishRunManifest(t, m.LocalWorkDir, m.SessionID, m.Campaign, m.RunID, nil)
for _, s := range stages {
result, err := s.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("stage %q returned unexpected error: %v", s.Name(), err)
}
if result == nil {
t.Fatalf("stage %q returned nil result", s.Name())
}
if s.Name() == "prepare" {
if result.Metadata["prepared"] != true {
t.Fatalf("prepare metadata = %#v, want prepared=true", result.Metadata)
}
continue
}
if s.Name() == "transcribe" {
if result.Metadata["stage"] != "transcribe" {
t.Fatalf("transcribe metadata = %#v, want stage=transcribe", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("transcribe outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "merge" {
if result.Metadata["stage"] != "merge" {
t.Fatalf("merge metadata = %#v, want stage=merge", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("merge outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "polish" {
if result.Metadata["stage"] != "polish" {
t.Fatalf("polish metadata = %#v, want stage=polish", result.Metadata)
}
if len(result.Outputs) == 0 {
t.Fatalf("polish outputs = %#v, want non-empty", result.Outputs)
}
continue
}
if s.Name() == "normalize" {
if result.Metadata["stage"] != "normalize" {
t.Fatalf("normalize metadata = %#v, want stage=normalize", result.Metadata)
}
if result.Metadata["output_schema"] == nil {
t.Fatalf("normalize metadata = %#v, want output_schema metadata", result.Metadata)
}
if len(result.Outputs) == 0 || result.Outputs[0].Kind != "transcript_final" {
t.Fatalf("normalize outputs = %#v, want transcript_final output", result.Outputs)
}
if len(result.Logs) != 2 {
t.Fatalf("normalize logs = %#v, want stdout+stderr", result.Logs)
}
if len(result.GeneratedConfigs) != 1 {
t.Fatalf("normalize generated configs = %#v, want one path", result.GeneratedConfigs)
}
continue
}
if s.Name() == "analyze" {
if result.Metadata["stage"] != "analyze" {
t.Fatalf("analyze metadata = %#v, want stage=analyze", result.Metadata)
}
if result.Metadata["skipped"] != true {
t.Fatalf("analyze metadata = %#v, want skipped=true when scriptorium is unconfigured", result.Metadata)
}
continue
}
if s.Name() == "trim" {
if result.Metadata["stage"] != "trim" {
t.Fatalf("trim metadata = %#v, want stage=trim", result.Metadata)
}
if result.Metadata["trim_action"] != "copy_disabled" {
t.Fatalf("trim metadata = %#v, want trim_action=copy_disabled", result.Metadata)
}
if len(result.Outputs) == 0 || result.Outputs[0].Kind != "transcript_final_trimmed" {
t.Fatalf("trim outputs = %#v, want transcript_final_trimmed output", result.Outputs)
}
continue
}
if s.Name() == "extract" {
if result.Disposition != StageDispositionSkipped || result.SkipReason != extractSkipReason {
t.Fatalf("extract result = %#v, want disabled self-skip", result)
}
continue
}
if s.Name() == "render" {
if result.Metadata["stage"] != "render" {
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
}
if len(result.Outputs) != 2 {
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
}
continue
}
if s.Name() == "publish" {
if result.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
}
if result.Metadata["uploaded"] != true {
t.Fatalf("publish metadata = %#v, want uploaded=true", result.Metadata)
}
continue
}
if result.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", s.Name())
}
}
whisperXRequests := wf.RequestsSnapshot()
if len(whisperXRequests) != 1 {
t.Fatalf("whisperx calls = %d, want 1", len(whisperXRequests))
}
if len(sf.Requests) != 1 {
t.Fatalf("seriatim calls = %d, want 1", len(sf.Requests))
}
if len(af.Requests) != 1 {
t.Fatalf("audita calls = %d, want 1", len(af.Requests))
}
if len(sc.RunRequests) != 0 {
t.Fatalf("scriptorium run calls = %d, want 0 when scriptorium config is absent", len(sc.RunRequests))
}
if _, ok := st.Objects["dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/"+m.RunID+"/manifest.json"]; !ok {
t.Fatalf("publish upload missing manifest key in fake object store")
}
if len(nf.Requests) != 1 {
t.Fatalf("notify calls = %d, want 1", len(nf.Requests))
}
}
func TestPlaceholderAdapterErrorPropagation(t *testing.T) {
cases := []struct {
stageName string
@@ -292,13 +49,3 @@ func TestPlaceholderAdapterErrorPropagation(t *testing.T) {
})
}
}
func writeStageTestFile(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)
}
}

View File

@@ -1203,3 +1203,13 @@ func boolPtr(v bool) *bool {
p := v
return &p
}
func writeStageTestFile(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)
}
}