Enforce continuous validation
This commit is contained in:
@@ -2,8 +2,33 @@ when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: build-release-assets
|
||||
validate:
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test ./...
|
||||
- go test -race ./...
|
||||
- go vet ./...
|
||||
- go build ./...
|
||||
- go test ./internal/doccheck
|
||||
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
|
||||
cross-build:
|
||||
image: golang:1.25
|
||||
depends_on: validate
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
output_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$output_dir"' EXIT
|
||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
||||
done
|
||||
|
||||
build-release-assets:
|
||||
image: golang:1.25
|
||||
depends_on: [validate, cross-build]
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -33,7 +58,7 @@ steps:
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
- name: publish-release
|
||||
publish-release:
|
||||
image: woodpeckerci/plugin-release
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
|
||||
8
.woodpecker/shuffle.yml
Normal file
8
.woodpecker/shuffle.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
when:
|
||||
- event: cron
|
||||
|
||||
steps:
|
||||
shuffled-race-tests:
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -shuffle=on -count=3 ./...
|
||||
47
.woodpecker/verify.yml
Normal file
47
.woodpecker/verify.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
when:
|
||||
- event: [push, pull_request]
|
||||
|
||||
steps:
|
||||
tests:
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test ./...
|
||||
|
||||
race-tests:
|
||||
image: golang:1.25
|
||||
depends_on: tests
|
||||
commands:
|
||||
- go test -race ./...
|
||||
|
||||
static-analysis:
|
||||
image: golang:1.25
|
||||
depends_on: tests
|
||||
commands:
|
||||
- go vet ./...
|
||||
|
||||
build:
|
||||
image: golang:1.25
|
||||
depends_on: tests
|
||||
commands:
|
||||
- go build ./...
|
||||
|
||||
documentation-and-examples:
|
||||
image: golang:1.25
|
||||
depends_on: tests
|
||||
commands:
|
||||
- go test ./internal/doccheck
|
||||
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
|
||||
cross-build:
|
||||
image: golang:1.25
|
||||
depends_on: [race-tests, static-analysis, build, documentation-and-examples]
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
output_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$output_dir"' EXIT
|
||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
||||
done
|
||||
@@ -32,12 +32,27 @@ contracts before changing behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Use focused package tests while iterating. Run the repository-wide checks when a
|
||||
change affects shared contracts, application behavior, or maintained
|
||||
documentation examples:
|
||||
Use focused package tests while iterating. Every pull request and push runs the
|
||||
following repository-wide checks before it can be accepted:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go build ./cmd/narratio
|
||||
go build ./...
|
||||
go test ./internal/doccheck
|
||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
```
|
||||
|
||||
The documentation check verifies local Markdown links and the dependency graph
|
||||
of the Woodpecker workflows. The configuration check loads every maintained
|
||||
pipeline and session example. Release automation repeats these checks and
|
||||
cross-compiles the CLI before it builds release assets; publishing depends on
|
||||
that validation path, so a failure cannot publish a release.
|
||||
|
||||
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
||||
job to expose ordering and repeatability defects. Current runners cross-compile
|
||||
for macOS and Windows, but do not provide native macOS or Windows execution.
|
||||
Those cross-builds establish compilation only, not platform-equivalent runtime
|
||||
evidence. Add native checks only when official runner labels and successful
|
||||
native-run evidence are available.
|
||||
|
||||
@@ -1046,6 +1046,8 @@ stage matrices only after the focused regression protections exist.
|
||||
exercise the local equivalents of every job, prove failure propagation to release
|
||||
eligibility, and inspect the final suite for duplicated matrices and lost cases.
|
||||
|
||||
**Status:** Completed.
|
||||
|
||||
## Stage 32 — Reconcile lifecycle/analyze documentation and close the remediation
|
||||
|
||||
**Read first:** `audit-findings.md` lines 3229–3255 (ARC-002, merged into
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: ¬ify.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
203
internal/doccheck/doccheck_test.go
Normal file
203
internal/doccheck/doccheck_test.go
Normal 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
|
||||
}
|
||||
@@ -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 := ¬ify.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user