package cli import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func testRunner() Runner { return testRunnerWithClock(fixedClock()) } func testRunnerWithClock(clock timeutil.Clock) Runner { return Runner{Clock: clock, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { return cliPromptExecutor{}, nil }} } type cliPromptExecutor struct{} func (cliPromptExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) { name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text") return promptexec.PromptInspection{ PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"}, }, nil } func (cliPromptExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil } func (cliPromptExecutor) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { now := time.Now().UTC() if err := callback(promptexec.Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath, StartedAt: now, EndedAt: now}, nil); err != nil { return nil, err } raw := []byte(`{"summary": "Showers are possible during the selected day.", "forecast_discussion": ["A front will keep rain chances in the forecast."], "precipitation_timing": "Rain is most likely during the afternoon."}`) if request.PromptID == "weather.today_generated_text" { raw = []byte(`{"summary": "Today starts with showers before improving.", "forecast_discussion": ["Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter."], "precipitation_timing": "The best rain chance is during the morning."}`) } if request.PromptID == "weather.hourly_generated_text" { raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region."}`) } return &promptexec.Execution{RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw, Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "generated_text.schema.json", nil)}, nil } func TestRunHelpLongFlag(t *testing.T) { output, err := runRootCommand(t, "--help") if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") { t.Fatalf("help output missing generate command:\n%s", output.stdout) } if !strings.Contains(output.stdout, "generate today") { t.Fatalf("help output missing today generate command:\n%s", output.stdout) } if !strings.Contains(output.stdout, "weatherreporter generate hourly") { t.Fatalf("help output missing hourly generate command:\n%s", output.stdout) } if strings.Count(output.stdout, "--llm-debug-dir PATH") != 5 { t.Fatalf("help output = %q, want debug flag for four generate commands and its option", output.stdout) } if !strings.Contains(output.stdout, "generate today") || !strings.Contains(output.stdout, "[--quiet]") { t.Fatalf("help output missing quiet generate usage:\n%s", output.stdout) } if !strings.Contains(output.stdout, "run morning") || !strings.Contains(output.stdout, "--quiet Suppress successful generate and run output.") { t.Fatalf("help output missing quiet run option:\n%s", output.stdout) } removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-") if strings.Contains(output.stdout, removedGenerateCommand) { t.Fatalf("help output includes retired generate command:\n%s", output.stdout) } removedInspectCommand := "inspect " + "briefing" if !strings.Contains(output.stdout, "inspect modules") || strings.Contains(output.stdout, removedInspectCommand) { t.Fatalf("help output has wrong inspect commands:\n%s", output.stdout) } } func TestRunHelpShortFlag(t *testing.T) { output, err := runRootCommand(t, "-h") if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(output.stdout, "weatherreporter run evening") { t.Fatalf("help output missing run command:\n%s", output.stdout) } } func TestRunUnknownCommand(t *testing.T) { _, err := runRootCommand(t, "unknown") if err == nil { t.Fatal("Run() error = nil, want unknown command error") } if !strings.Contains(err.Error(), `unknown command "unknown"`) { t.Fatalf("Run() error = %q, want unknown command message", err.Error()) } } func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) outPath := fixture.path("tomorrow.md") runner := testRunner() _, err := runTestCommand(t, runner, "generate", "tomorrow", "--config", fixture.configPath, "--out", outPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } assertFileContains(t, outPath, "# Saturday's Weather") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") assertFileContains(t, dataPackagePath, "id: tomorrow") assertFileContains(t, dataPackagePath, "tomorrow_planning:") reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") if !strings.Contains(filepath.Base(reportPath), "tomorrow") { t.Fatalf("managed report = %q, want tomorrow report", reportPath) } } func TestRunGenerateWritesRequestedDebugAndSafeSummary(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) debugDir := fixture.path("prompt-debug") output, err := runTestCommand(t, testRunner(), "generate", "today", "--config", fixture.configPath, "--llm-debug-dir", debugDir, ) if err != nil { t.Fatalf("Run() error = %v", err) } summary := decodeGenerateSummary(t, output.stdout) if summary.LLMDebugPath == "" || !strings.HasPrefix(summary.LLMDebugPath, debugDir+string(filepath.Separator)) { t.Fatalf("debug path = %q, want isolated requested directory", summary.LLMDebugPath) } if strings.Contains(output.stdout, "Showers are possible during the selected day") || strings.Contains(output.stdout, "Today starts with showers before improving") || strings.Contains(output.stdout, "rendered-hash") { t.Fatalf("summary contains prompt or generated content:\n%s", output.stdout) } assertFileContains(t, filepath.Join(summary.LLMDebugPath, "preparation.json"), "weather.today_generated_text") assertFileContains(t, filepath.Join(summary.LLMDebugPath, "execution.json"), "Today starts with showers before improving.") } func TestParseGenerateFlagsAcceptsDebugDirectoryForEveryReport(t *testing.T) { for _, reportKind := range []app.ReportKind{app.ReportDaily, app.ReportToday, app.ReportTomorrow, app.ReportHourly} { t.Run(string(reportKind), func(t *testing.T) { opts, err := parseGenerateFlags(reportKind, []string{"--llm-debug-dir", "/tmp/prompt-debug"}) if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" { t.Fatalf("parseGenerateFlags() options/error = %#v/%v", opts, err) } }) } } func TestRunEveningGeneratesTomorrowReport(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := testRunner() _, err := runTestCommand(t, runner, "run", "evening", "--config", fixture.configPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") if !strings.Contains(filepath.Base(reportPath), "tomorrow") { t.Fatalf("managed report = %q, want only tomorrow report", reportPath) } } func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := testRunner() _, err := runTestCommand(t, runner, "run", "morning", "--config", fixture.configPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") } func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { fixture := newCLIFixture(t, writeFailingScriptorium) runner := testRunner() output, err := runTestCommand(t, runner, "run", "morning", "--config", fixture.configPath, ) if err == nil { t.Fatal("Run() error = nil, want aggregate failure") } if !strings.Contains(err.Error(), "1 of 2 reports failed") { t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) } summary := decodeBatchSummary(t, output.stdout) if summary.Command != "run" || summary.Status != "failed" { t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status) } if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed) } if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") { t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") } func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) { result := &app.BatchResult{ Batch: app.BatchMorning, Total: 2, Succeeded: 2, Failed: 0, Notification: &app.BatchNotificationResult{ Status: "succeeded", RunID: "batch-distributor-run", PipelineID: "weatherreporter", BundleID: "weatherreporter.home.morning", IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning", Path: "/tmp/distributor.batch.json", IncludedReports: []app.BatchNotificationReport{ {ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}}, }, }, Reports: []app.BatchReportResult{ { ReportID: "daily", Status: "succeeded", OutputPath: "/tmp/daily.md", }, { ReportID: "tomorrow", Status: "succeeded", OutputPath: "/tmp/tomorrow.md", }, }, } var stdout bytes.Buffer var stderr bytes.Buffer if err := writeJSON(&stdout, result); err != nil { t.Fatalf("writeJSON() error = %v", err) } writeBatchStatus(&stderr, result) var decoded app.BatchResult if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String()) } if decoded.Notification == nil || decoded.Notification.Status != "succeeded" || decoded.Notification.RunID != "batch-distributor-run" || decoded.Notification.PipelineID != "weatherreporter" || len(decoded.Notification.IncludedReports) != 1 { t.Fatalf("top-level notification = %#v, want succeeded batch notification", decoded.Notification) } for _, report := range decoded.Reports { if report.NotificationStatus != "" || report.NotificationRunID != "" || report.NotificationError != "" { t.Fatalf("report notification fields = %#v, want empty", report) } } if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) } if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) || !strings.Contains(stderr.String(), `pipelineId="weatherreporter"`) { t.Fatalf("stderr missing batch notification details:\n%s", stderr.String()) } if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") { t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String()) } } func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) { result := &app.BatchResult{ Batch: app.BatchMorning, Total: 1, Failed: 1, Notification: &app.BatchNotificationResult{ Status: "failed", Error: "notify batch morning: upload failed: [redacted]", }, Reports: []app.BatchReportResult{ { ReportID: "daily", Status: "succeeded", }, }, } var stdout bytes.Buffer var stderr bytes.Buffer if err := writeJSON(&stdout, result); err != nil { t.Fatalf("writeJSON() error = %v", err) } writeBatchStatus(&stderr, result) for _, output := range []string{stdout.String(), stderr.String()} { if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") { t.Fatalf("output contains token value:\n%s", output) } if !strings.Contains(output, "[redacted]") { t.Fatalf("output missing redacted marker:\n%s", output) } } } func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) { result := &app.BatchResult{ Batch: app.BatchMorning, Total: 2, Succeeded: 1, Failed: 1, Notification: &app.BatchNotificationResult{ Status: "skipped", Reason: "one or more reports failed", }, Reports: []app.BatchReportResult{ {ReportID: "today", Status: "succeeded", OutputPath: "/tmp/today.md"}, {ReportID: "tomorrow", Status: "failed", Error: "render failed"}, }, } var stderr bytes.Buffer var stdout bytes.Buffer if err := writeJSON(&stdout, result); err != nil { t.Fatalf("writeJSON() error = %v", err) } writeBatchStatus(&stderr, result) var decoded app.BatchResult if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String()) } if decoded.Notification == nil || decoded.Notification.Status != "skipped" || decoded.Notification.Reason != "one or more reports failed" { t.Fatalf("top-level notification = %#v, want skipped notification", decoded.Notification) } if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) } if !strings.Contains(stderr.String(), `batchNotification status="skipped" reason="one or more reports failed"`) { t.Fatalf("stderr missing skipped batch notification:\n%s", stderr.String()) } } func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) { result := &app.BatchResult{ Batch: app.BatchEvening, Total: 1, Succeeded: 1, Failed: 1, Notification: &app.BatchNotificationResult{ Status: "failed", Error: "notify batch evening: upload failed", }, Reports: []app.BatchReportResult{ {ReportID: "tomorrow", Status: "succeeded", OutputPath: "/tmp/tomorrow.md"}, }, } var stderr bytes.Buffer writeBatchStatus(&stderr, result) if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) } if count := strings.Count(stderr.String(), "notify batch evening: upload failed"); count != 1 { t.Fatalf("stderr batch notification error occurrences = %d, want one:\n%s", count, stderr.String()) } reportLine := firstLineWithPrefix(stderr.String(), "report=tomorrow ") if strings.Contains(reportLine, "notify batch evening") || strings.Contains(reportLine, "notificationError") { t.Fatalf("report line repeats batch notification error:\n%s", reportLine) } } func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) outputDir := fixture.path("copies") runner := testRunner() output, err := runTestCommand(t, runner, "run", "evening", "--config", fixture.configPath, "--out-dir", outputDir, ) if err != nil { t.Fatalf("Run() error = %v", err) } summary := decodeBatchSummary(t, output.stdout) if summary.Command != "run" || summary.Status != "succeeded" { t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if summary.Total != 1 || summary.Failed != 0 { t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed) } if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil { t.Fatalf("expected copied report: %v", err) } if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") { t.Fatalf("summary reports = %#v, want output path", summary.Reports) } } func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := testRunner() output, err := runTestCommand(t, runner, "run", "evening", "--config", fixture.configPath, "--quiet", ) if err != nil { t.Fatalf("Run() error = %v", err) } if output.stdout != "" || output.stderr != "" { t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) } _ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") } func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) { server := dailyServer(t) var uploadCount int distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/runs/batch-distributor-run" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","pipeline_id":"weatherreporter","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`)) return } if r.URL.Path != "/v1/pipelines/weatherreporter/upload" { http.NotFound(w, r) return } uploadCount++ w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","status":"accepted"}`)) })) t.Cleanup(distributorServer.Close) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{ "run", "evening", "--config", configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } summary := decodeBatchSummary(t, stdout.String()) if summary.Command != "run" || summary.Status != "succeeded" { t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if len(summary.Reports) != 1 { t.Fatalf("reports = %#v, want one report", summary.Reports) } if uploadCount != 1 { t.Fatalf("batch upload count = %d, want 1", uploadCount) } if summary.Notification == nil || summary.Notification.Status != "succeeded" || summary.Notification.RunID != "batch-distributor-run" { t.Fatalf("batch notification = %#v, want succeeded batch notification", summary.Notification) } if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) } if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) { t.Fatalf("stderr missing batch notification success:\n%s", stderr.String()) } if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationRunID != "" || summary.Reports[0].NotificationPipelineID != "" || summary.Reports[0].NotificationError != "" || summary.Reports[0].NotificationPath != "" { t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0]) } if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") { t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String()) } if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") { t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) } } func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T) { server := dailyServer(t) distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected distributor request %s", r.URL.Path) })) t.Cleanup(distributorServer.Close) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{ "run", "evening", "--config", configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } summary := decodeBatchSummary(t, stdout.String()) if summary.Command != "run" || summary.Status != "succeeded" { t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) } if len(summary.Reports) != 1 { t.Fatalf("summary reports = %#v, want one report", summary.Reports) } if summary.Notification != nil { t.Fatalf("batch notification = %#v, want omitted when disabled", summary.Notification) } if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationError != "" { t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0]) } } func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}} err := runner.Run(context.Background(), []string{ "run", "morning", "--config", fixture.configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml") } func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) outPath := fixture.path("daily.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{ "generate", "daily", "--config", fixture.configPath, "--date", "2026-05-29", "--tz", "UTC", "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(reportData), "# Friday's Weather") { t.Fatalf("report output missing markdown:\n%s", string(reportData)) } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") data, err := os.ReadFile(dataPackagePath) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || !strings.Contains(string(data), "id: daily") { t.Fatalf("data package output missing expected content:\n%s", string(data)) } if !strings.Contains(string(data), "location:") || !strings.Contains(string(data), "id: home") || !strings.Contains(string(data), "name: Brentwood") || !strings.Contains(string(data), "region: St. Louis Metro") || !strings.Contains(string(data), "timezone: UTC") { t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) } preparationPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "prompt_preparation.*.json") preparation, err := os.ReadFile(preparationPath) if err != nil { t.Fatalf("read preparation: %v", err) } if !strings.Contains(string(preparation), `"status": "succeeded"`) { t.Fatalf("preparation missing successful status:\n%s", string(preparation)) } _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text.*.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "render_context.*.json") metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "metadata.*.json") assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`) assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`) assertFileContains(t, metadataPath, `"generatedTextSchemaId": "daily"`) } func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { fixture := newCLIFixture(t, writeStructuredOutputScriptorium) outPath := fixture.path("today.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{ "generate", "today", "--config", fixture.configPath, "--date", "2026-05-29", "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } for _, want := range []string{ "# Today's Weather", "Today starts with showers before improving.", "Morning showers should taper as drier air arrives.", } { if !strings.Contains(string(reportData), want) { t.Fatalf("today report output missing %q:\n%s", want, string(reportData)) } } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") dataPackage, err := os.ReadFile(dataPackagePath) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(dataPackage), "id: today") || !strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") || !strings.Contains(string(dataPackage), "today_planning:") { t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) } noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text_raw.*.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text.*.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "render_context.*.json") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md") assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) assertFileContains(t, managedReportPath, "# Today's Weather") summary := decodeGenerateSummary(t, stdout.String()) if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today { t.Fatalf("generate summary = %#v, want successful Today summary", summary) } if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" { t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary) } if summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) } if summary.OutputPath != outPath { t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath) } } func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { fixture := newCLIFixture(t, writeStructuredOutputScriptorium) outPath := fixture.path("hourly.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunnerWithClock(timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}) err := runner.Run(context.Background(), []string{ "generate", "hourly", "--config", fixture.configPath, "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } reportData, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } for _, want := range []string{ "# Hourly Report", "Storm chances increase through late morning.", "A cold front is moving into the region.", "A front will keep the region unsettled.", } { if !strings.Contains(string(reportData), want) { t.Fatalf("report output missing %q:\n%s", want, string(reportData)) } } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml") dataPackage, err := os.ReadFile(dataPackagePath) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(dataPackage), "id: hourly") || !strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") || !strings.Contains(string(dataPackage), "hourly_forecast:") { t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage)) } rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text_raw.*.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md") assertFileContains(t, rawGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) assertFileContains(t, renderContextPath, `"Report": {`) assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`) assertFileContains(t, renderContextPath, `"Modules": {`) assertFileContains(t, renderContextPath, `"Collected": {`) assertFileContains(t, renderContextPath, `"Derived": {`) assertFileContains(t, managedReportPath, "# Hourly Report") } func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) { fixture := newCLIFixture(t, writeStructuredOutputScriptorium) outPath := fixture.path("today.md") debugDir := fixture.path("prompt-debug") runner := testRunner() output, err := runTestCommand(t, runner, "generate", "today", "--config", fixture.configPath, "--date", "2026-05-29", "--out", outPath, "--llm-debug-dir", debugDir, "--quiet", ) if err != nil { t.Fatalf("Run() error = %v", err) } if output.stdout != "" || output.stderr != "" { t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) } assertFileContains(t, outPath, "# Today's Weather") _ = oneArtifact(t, debugDir, "today", "2026-05-29", "*", "preparation.json") } func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want required date error") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want no partial JSON", stdout.String()) } } func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) { server := dailyServer(t) distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path) })) t.Cleanup(distributorServer.Close) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) t.Setenv("CLI_DISTRIBUTOR_TOKEN", "") runner := testRunner() output, err := runTestCommand(t, runner, "generate", "daily", "--config", configPath, "--date", "2026-05-29", ) if err == nil { t.Fatal("Run() error = nil, want notification failure") } summary := decodeGenerateSummary(t, output.stdout) if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" { t.Fatalf("summary = %#v, want failed generate summary", summary) } if !strings.Contains(summary.Error, "token environment variable") { t.Fatalf("summary error = %q, want token environment context", summary.Error) } if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" { t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary) } if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") { t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr) } } func TestRunInspectTodayArtifacts(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := testRunner() var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{ "generate", "today", "--config", fixture.configPath, "--date", "2026-05-29", }, &stdout, &stderr) if err != nil { t.Fatalf("Run(generate) error = %v", err) } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") runID := runIDFromDataPackagePath(t, dataPackagePath) stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect reports) error = %v", err) } if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"reportId": "today"`) { t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String()) } var sourcesOutput string for _, command := range []string{"metadata", "modules", "data-package", "sources"} { stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect %s) error = %v", command, err) } if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") { t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String()) } if command == "sources" { sourcesOutput = stdout.String() } } if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) } } func TestRunInspectGeneratedArtifacts(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := testRunner() var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{ "generate", "daily", "--config", fixture.configPath, "--date", "2026-05-29", }, &stdout, &stderr) if err != nil { t.Fatalf("Run(generate) error = %v", err) } dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") runID := runIDFromDataPackagePath(t, dataPackagePath) stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect reports) error = %v", err) } if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) { t.Fatalf("inspect reports output missing run:\n%s", stdout.String()) } var sourcesOutput string for _, command := range []string{"metadata", "modules", "data-package", "sources"} { stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect %s) error = %v", command, err) } if !strings.Contains(stdout.String(), runID) { t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String()) } if command == "sources" { sourcesOutput = stdout.String() } } if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) } } func TestRunInspectMissingMetadata(t *testing.T) { tempDir := t.TempDir() configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr) if err == nil { t.Fatal("Run(inspect metadata) error = nil, want missing metadata error") } if !strings.Contains(err.Error(), "metadata for run id") { t.Fatalf("error = %q, want missing run id context", err.Error()) } } func TestRunInspectRejectsQuiet(t *testing.T) { tempDir := t.TempDir() configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) var stdout bytes.Buffer var stderr bytes.Buffer runner := testRunner() err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr) if err == nil { t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error") } if !strings.Contains(err.Error(), "flag provided but not defined") { t.Fatalf("error = %q, want unexpected quiet flag", err.Error()) } } func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { tempDir := t.TempDir() configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) runner := testRunner() commands := []string{"metadata", "modules", "data-package", "prior", "sources"} for _, command := range commands { t.Run(command+" requires run id", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want missing run id error") } if !strings.Contains(err.Error(), "requires a run id") { t.Fatalf("error = %q, want missing run id context", err.Error()) } }) t.Run(command+" accepts config", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want missing metadata error") } if !strings.Contains(err.Error(), "metadata for run id") { t.Fatalf("error = %q, want missing metadata context", err.Error()) } }) } } func TestResolveGenerateCommands(t *testing.T) { runner := testRunner() tests := []struct { name string args []string want app.ReportKind }{ {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, {name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "hourly", args: []string{"hourly"}, want: app.ReportHourly}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := runner.resolveGenerate(tt.args) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Report != tt.want { t.Fatalf("Report = %q, want %q", req.Report, tt.want) } }) } } func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) { runner := testRunner() for _, name := range report.CommandNames() { t.Run(name, func(t *testing.T) { args := []string{name} if name == report.CommandNameDaily || name == report.CommandNameToday { args = append(args, "--date", "2026-05-29") } req, err := runner.resolveGenerate(args) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } want, err := report.IDForCommandName(name) if err != nil { t.Fatalf("IDForCommandName() error = %v", err) } resolved, err := app.ResolveGenerate(req, req.Now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } if resolved.Definition.ID != want { t.Fatalf("resolved ID = %q, want %q", resolved.Definition.ID, want) } }) } } func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) { runner := testRunner() configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n") req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Report != app.ReportHourly { t.Fatalf("Report = %q, want hourly", req.Report) } if req.Config.WeatherAPI.Units != "us" { t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units) } if req.Config.WeatherAPI.Timezone != "America/Chicago" { t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone) } if req.OutputPath != "./hourly.md" { t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath) } if !req.Date.IsZero() { t.Fatalf("Date = %s, want unset for hourly", req.Date) } } func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) { runner := testRunner() for _, args := range [][]string{ {"hourly", "--date", "2026-05-29"}, {"hourly", "--start", "2026-05-29T18:00"}, {"hourly", "--end", "2026-05-29T20:00"}, {"hourly", "--hours", "6"}, {"hourly", "--duration", "6h"}, } { _, err := runner.resolveGenerate(args) if err == nil { t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args) } if !strings.Contains(err.Error(), "flag provided but not defined") { t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error()) } } } func TestResolveGenerateDailyRequiresDate(t *testing.T) { runner := testRunner() req, err := runner.resolveGenerate([]string{"daily"}) if err == nil { t.Fatal("resolveGenerate() error = nil, want required date error") } if !strings.Contains(err.Error(), "generate daily requires --date YYYY-MM-DD") { t.Fatalf("resolveGenerate() error = %q, want required date context", err.Error()) } if !req.Date.IsZero() { t.Fatalf("Date = %s, want unset on error", req.Date) } } func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) { runner := testRunner() _, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"}) if err == nil { t.Fatal("resolveGenerate() error = nil, want date parse error") } if !strings.Contains(err.Error(), `parse date "bad-date" as YYYY-MM-DD`) { t.Fatalf("resolveGenerate() error = %q, want date parse context", err.Error()) } } func TestResolveGenerateDailyParsesDate(t *testing.T) { runner := testRunner() req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Report != app.ReportDaily { t.Fatalf("Report = %q, want daily", req.Report) } if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { t.Fatalf("Date = %s, want 2026-05-29", got) } } func TestResolveGenerateTodayDate(t *testing.T) { runner := testRunner() defaultReq, err := runner.resolveGenerate([]string{"today"}) if err != nil { t.Fatalf("resolveGenerate(default) error = %v", err) } if defaultReq.Report != app.ReportToday { t.Fatalf("Report = %q, want today", defaultReq.Report) } if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" { t.Fatalf("default Date = %s, want 2026-05-29", got) } explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"}) if err != nil { t.Fatalf("resolveGenerate(explicit) error = %v", err) } if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" { t.Fatalf("explicit Date = %s, want 2026-05-30", got) } resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } if resolved.Definition.ID != report.Today { t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID) } if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" { t.Fatalf("valid period start = %s, want explicit UTC date", got) } } func TestResolveGenerateAppliesSharedFlags(t *testing.T) { runner := testRunner() req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Config.WeatherAPI.Units != "metric" { t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units) } if req.Config.WeatherAPI.Timezone != "UTC" { t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone) } if req.OutputPath != "./daily.md" { t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath) } } func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) { runner := testRunner() retired := strings.Join([]string{"near", "term"}, "-") _, err := runner.resolveGenerate([]string{retired}) if err == nil { t.Fatal("resolveGenerate() error = nil, want unknown report") } if !strings.Contains(err.Error(), "unknown generate report") { t.Fatalf("error = %q, want unknown generate report", err.Error()) } } func TestResolveGenerateRejectsRetiredReports(t *testing.T) { runner := testRunner() for _, name := range []string{"three-day", "weekend", "storm"} { if _, err := runner.resolveGenerate([]string{name}); err == nil { t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name) } } } func TestResolveRunCommands(t *testing.T) { tests := []struct { name string args []string want app.BatchKind }{ {name: "morning", args: []string{"morning"}, want: app.BatchMorning}, {name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := resolveRun(tt.args) if err != nil { t.Fatalf("resolveRun() error = %v", err) } if req.Batch != tt.want { t.Fatalf("Batch = %q, want %q", req.Batch, tt.want) } }) } } func TestResolveRunRejectsOutputFlag(t *testing.T) { _, err := resolveRun([]string{"morning", "--out", "./report.md"}) if err == nil { t.Fatal("resolveRun() error = nil, want flag error") } if !strings.Contains(err.Error(), "flag provided but not defined") { t.Fatalf("error = %q, want undefined flag error", err.Error()) } } func TestResolveRunAppliesOutputDirectory(t *testing.T) { req, err := resolveRun([]string{"evening", "--out-dir", "./reports"}) if err != nil { t.Fatalf("resolveRun() error = %v", err) } if req.OutputDir != "./reports" { t.Fatalf("OutputDir = %q, want ./reports", req.OutputDir) } } func fixedClock() timeutil.Clock { return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} } type commandOutput struct { stdout string stderr string } func runRootCommand(t *testing.T, args ...string) (commandOutput, error) { t.Helper() return runTestCommand(t, Runner{}, args...) } func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput, error) { t.Helper() var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), args, &stdout, &stderr) return commandOutput{ stdout: stdout.String(), stderr: stderr.String(), }, err } func decodeGenerateSummary(t *testing.T, text string) generateSummary { t.Helper() var summary generateSummary if err := json.Unmarshal([]byte(text), &summary); err != nil { t.Fatalf("decode generate summary: %v\n%s", err, text) } return summary } func decodeBatchSummary(t *testing.T, text string) batchSummary { t.Helper() var summary batchSummary if err := json.Unmarshal([]byte(text), &summary); err != nil { t.Fatalf("decode batch summary: %v\n%s", err, text) } return summary } func dailyServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) case "/weatherstories/latest": _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) case "/outlooks/convective": _, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`)) default: http.NotFound(w, r) } })) t.Cleanup(server.Close) return server } type cliFixture struct { tempDir string workspaceRoot string configPath string } func newCLIFixture(t *testing.T, writeScriptorium func(*testing.T, string) string) cliFixture { t.Helper() server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") return cliFixture{ tempDir: tempDir, workspaceRoot: workspaceRoot, configPath: writeTestConfig(t, server, scriptoriumPath, workspaceRoot), } } func (f cliFixture) path(name string) string { return filepath.Join(f.tempDir, name) } func writeConfigFile(t *testing.T, body string) string { t.Helper() configPath := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) } return configPath } func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string { t.Helper() configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" return writeConfigFile(t, configBody) } func writeTestConfigWithDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string { t.Helper() configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n" return writeConfigFile(t, configBody) } func writeTestConfigWithDisabledBatchDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string { t.Helper() configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n batch:\n enabled: false\n" return writeConfigFile(t, configBody) } func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string { t.Helper() return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n") } func oneArtifact(t *testing.T, root string, parts ...string) string { t.Helper() matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) if err != nil { t.Fatalf("glob artifact: %v", err) } if len(matches) != 1 { t.Fatalf("artifact matches = %#v, want one", matches) } return matches[0] } func noArtifacts(t *testing.T, root string, parts ...string) { t.Helper() matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) if err != nil { t.Fatalf("glob artifact: %v", err) } if len(matches) != 0 { t.Fatalf("artifact matches = %#v, want none", matches) } } func runIDFromDataPackagePath(t *testing.T, path string) string { t.Helper() base := filepath.Base(path) runID := strings.TrimSuffix(strings.TrimPrefix(base, "data_package."), ".yaml") if runID == base || runID == "" { t.Fatalf("data package path = %q, want data_package..yaml", path) } return runID } func firstLineWithPrefix(text string, prefix string) string { for _, line := range strings.Split(text, "\n") { if strings.HasPrefix(line, prefix) { return line } } return "" } func assertFileContains(t *testing.T, path string, want string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } if !strings.Contains(string(data), want) { t.Fatalf("%s missing %q:\n%s", path, want, string(data)) } } func writeFakeScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") body := `#!/bin/sh if [ "$1" = "render" ]; then printf '{"ok":true,"argv":"%s"}' "$*" exit 0 fi if [ "$1" = "run" ]; then out="" prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" elif [ "$1" = "--prompt" ]; then shift prompt="$1" fi shift done if [ "$prompt" = "weather.today_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Today starts with showers before improving.", "forecast_discussion": [ "Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter." ], "precipitation_timing": "The best rain chance is during the morning." } JSON printf 'wrote generated text\n' >&2 exit 0 fi if [ "$prompt" = "weather.tomorrow_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Tomorrow starts with showers before improving.", "forecast_discussion": [ "Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter." ], "precipitation_timing": "The best rain chance is during the morning." } JSON printf 'wrote generated text\n' >&2 exit 0 fi if [ "$prompt" = "weather.daily_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Showers are possible during the selected day.", "forecast_discussion": [ "A front will keep rain chances in the forecast.", "Temperatures stay seasonable by afternoon." ], "precipitation_timing": "Rain is most likely during the afternoon.", "confidence": "Medium" } JSON printf 'wrote generated text\n' >&2 exit 0 fi printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" printf 'wrote report\n' >&2 exit 0 fi printf 'unexpected command\n' >&2 exit 1 ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatalf("write fake scriptorium: %v", err) } return path } func writeStructuredOutputScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") body := `#!/bin/sh if [ "$1" = "render" ]; then printf '{"ok":true,"argv":"%s"}' "$*" exit 0 fi if [ "$1" = "run" ]; then out="" prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" elif [ "$1" = "--prompt" ]; then shift prompt="$1" fi shift done if [ "$prompt" = "weather.hourly_generated_text" ]; then cat > "$out" <<'JSON' { "summary": " Storm chances increase through late morning. ", "forecast_discussion": "A front will keep the region unsettled.", "precipitation_timing": "A cold front is moving into the region.", "confidence": "Medium" } JSON printf 'wrote generated text\n' >&2 exit 0 fi if [ "$prompt" = "weather.daily_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Showers are possible during the selected day.", "forecast_discussion": [ "A front will keep rain chances in the forecast.", "Temperatures stay seasonable by afternoon." ], "precipitation_timing": "Rain is most likely during the afternoon.", "confidence": "Medium" } JSON printf 'wrote generated text\n' >&2 exit 0 fi if [ "$prompt" = "weather.today_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Today starts with showers before improving.", "forecast_discussion": [ "Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter." ], "precipitation_timing": "The best rain chance is during the morning." } JSON printf 'wrote generated text\n' >&2 exit 0 fi if [ "$prompt" = "weather.tomorrow_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Tomorrow starts with showers before improving.", "forecast_discussion": [ "Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter." ], "precipitation_timing": "The best rain chance is during the morning." } JSON printf 'wrote generated text\n' >&2 exit 0 fi printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" printf 'wrote report\n' >&2 exit 0 fi printf 'unexpected command\n' >&2 exit 1 ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatalf("write fake scriptorium: %v", err) } return path } func writeFailingScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") body := `#!/bin/sh if [ "$1" = "render" ]; then prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--prompt" ]; then shift prompt="$1" fi shift done if [ "$prompt" = "weather.tomorrow_generated_text" ]; then printf 'render failed\n' >&2 exit 1 fi printf '{"ok":true,"prompt":"%s"}' "$prompt" exit 0 fi if [ "$1" = "run" ]; then out="" prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" elif [ "$1" = "--prompt" ]; then shift prompt="$1" fi shift done if [ "$prompt" = "weather.today_generated_text" ]; then cat > "$out" <<'JSON' { "summary": "Today starts with showers before improving.", "forecast_discussion": [ "Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter." ], "precipitation_timing": "The best rain chance is during the morning." } JSON exit 0 fi printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out" exit 0 fi printf 'unexpected command\n' >&2 exit 1 ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatalf("write fake scriptorium: %v", err) } return path }