diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 05ce7f8..0af77ba 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -65,17 +65,13 @@ func TestRunUnknownCommand(t *testing.T) { } func TestRunGenerateStormWritesMarkdownReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "storm.md") + fixture := newCLIFixture(t, writeFakeScriptorium) + outPath := fixture.path("storm.md") runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "generate", "storm", - "--config", configPath, + "--config", fixture.configPath, "--start", "2026-05-29T06:00", "--end", "2026-05-29T10:00", "--out", outPath, @@ -84,143 +80,113 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) { t.Fatalf("Run() error = %v", err) } assertFileContains(t, outPath, "# Daily Report") - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml") + dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml") assertFileContains(t, dataPackagePath, "id: storm") assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report") } func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "tomorrow.md") + fixture := newCLIFixture(t, writeFakeScriptorium) + outPath := fixture.path("tomorrow.md") runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "generate", "tomorrow", - "--config", configPath, + "--config", fixture.configPath, "--out", outPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } assertFileContains(t, outPath, "# Saturday's Weather") - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") + 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, workspaceRoot, "reports", "tomorrow", "*.md") + reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md") if !strings.Contains(filepath.Base(reportPath), "tomorrow") { t.Fatalf("managed report = %q, want tomorrow report", reportPath) } } func TestRunEveningGeneratesTomorrowReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "run", "evening", - "--config", configPath, + "--config", fixture.configPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } - _ = oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") - reportPath := oneArtifact(t, workspaceRoot, "reports", "tomorrow", "*.md") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") + reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md") if !strings.Contains(filepath.Base(reportPath), "tomorrow") { t.Fatalf("managed report = %q, want only tomorrow report", reportPath) } } func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "three-day.md") + fixture := newCLIFixture(t, writeFakeScriptorium) + outPath := fixture.path("three-day.md") runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "generate", "three-day", - "--config", configPath, + "--config", fixture.configPath, "--out", outPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } assertFileContains(t, outPath, "# Daily Report") - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml") + dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml") assertFileContains(t, dataPackagePath, "id: three_day") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") } func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "weekend.md") + fixture := newCLIFixture(t, writeFakeScriptorium) + outPath := fixture.path("weekend.md") runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "generate", "weekend", - "--config", configPath, + "--config", fixture.configPath, "--out", outPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } assertFileContains(t, outPath, "# Daily Report") - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") assertFileContains(t, dataPackagePath, "id: weekend") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") } func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} _, err := runTestCommand(t, runner, "run", "morning", - "--config", configPath, + "--config", fixture.configPath, ) if err != nil { t.Fatalf("Run() error = %v", err) } - _ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") - _ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") - dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob daily packages: %v", err) - } - if len(dailyPackages) != 0 { - t.Fatalf("daily packages = %#v, want morning batch to use Today only", dailyPackages) - } + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") } func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFailingScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + fixture := newCLIFixture(t, writeFailingScriptorium) runner := Runner{Clock: fixedClock()} output, err := runTestCommand(t, runner, "run", "morning", - "--config", configPath, + "--config", fixture.configPath, ) if err == nil { t.Fatal("Run() error = nil, want aggregate failure") @@ -239,8 +205,8 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { 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, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") - _ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") } func TestBatchOutputIncludesNotificationDetails(t *testing.T) { @@ -326,17 +292,13 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) { } func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - outputDir := filepath.Join(tempDir, "copies") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + fixture := newCLIFixture(t, writeFakeScriptorium) + outputDir := fixture.path("copies") runner := Runner{Clock: fixedClock()} output, err := runTestCommand(t, runner, "run", "evening", - "--config", configPath, + "--config", fixture.configPath, "--out-dir", outputDir, ) if err != nil { @@ -375,12 +337,8 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) { t.Cleanup(distributorServer.Close) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") - 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: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") var stdout bytes.Buffer var stderr bytes.Buffer @@ -421,12 +379,8 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) { t.Cleanup(distributorServer.Close) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") - 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: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") var stdout bytes.Buffer var stderr bytes.Buffer @@ -460,61 +414,33 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) { } func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") - workspaceRoot := filepath.Join(tempDir, "workspace") - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + 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", configPath, + "--config", fixture.configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } - todayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob today packages: %v", err) - } - threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob 3-day packages: %v", err) - } - dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob daily packages: %v", err) - } - if len(todayPackages) != 1 || len(threeDayPackages) != 1 || len(dailyPackages) != 0 { - t.Fatalf("today packages = %#v, 3-day packages = %#v, daily packages = %#v; want Today and 3-day only", todayPackages, threeDayPackages, dailyPackages) - } + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml") } func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") - workspaceRoot := filepath.Join(tempDir, "workspace") - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - outPath := filepath.Join(tempDir, "daily.md") + fixture := newCLIFixture(t, writeFakeScriptorium) + outPath := fixture.path("daily.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "daily", - "--config", configPath, + "--config", fixture.configPath, "--date", "2026-05-29", "--tz", "UTC", "--out", outPath, @@ -529,14 +455,8 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { if !strings.Contains(string(report), "# Friday's Weather") { t.Fatalf("report output missing markdown:\n%s", string(report)) } - dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob data package: %v", err) - } - if len(dataPackageMatches) != 1 { - t.Fatalf("data package files = %#v, want one", dataPackageMatches) - } - data, err := os.ReadFile(dataPackageMatches[0]) + 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) } @@ -550,31 +470,19 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { !strings.Contains(string(data), "timezone: UTC") { t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) } - preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) - if err != nil { - t.Fatalf("glob preflight: %v", err) - } - if len(preflightMatches) != 1 { - t.Fatalf("preflight files = %#v, want one render output", preflightMatches) - } - preflight, err := os.ReadFile(preflightMatches[0]) + preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json") + preflight, err := os.ReadFile(preflightPath) if err != nil { t.Fatalf("read preflight: %v", err) } if !strings.Contains(string(preflight), `ok`) { t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) } - reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) - if err != nil { - t.Fatalf("glob managed report: %v", err) - } - if len(reportMatches) != 1 { - t.Fatalf("managed reports = %#v, want one", reportMatches) - } - rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json") - validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json") - renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json") - metadataPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json") + _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "*.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"`) @@ -582,19 +490,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { } func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "today.md") + fixture := newCLIFixture(t, writeStructuredOutputScriptorium) + outPath := fixture.path("today.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "today", - "--config", configPath, + "--config", fixture.configPath, "--date", "2026-05-29", "--out", outPath, }, &stdout, &stderr) @@ -614,7 +518,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { t.Fatalf("today report output missing %q:\n%s", want, string(report)) } } - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") + 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) @@ -624,17 +528,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { !strings.Contains(string(dataPackage), "today_planning:") { t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) } - dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob daily packages: %v", err) - } - if len(dailyPackages) != 0 { - t.Fatalf("daily packages = %#v, want generate today to stay separate", dailyPackages) - } - rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json") - validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json") - renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json") - managedReportPath := oneArtifact(t, workspaceRoot, "reports", "today", "*.md") + 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", "*.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"`) @@ -642,19 +540,15 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { } func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) - outPath := filepath.Join(tempDir, "hourly.md") + fixture := newCLIFixture(t, writeStructuredOutputScriptorium) + outPath := fixture.path("hourly.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}} err := runner.Run(context.Background(), []string{ "generate", "hourly", - "--config", configPath, + "--config", fixture.configPath, "--out", outPath, }, &stdout, &stderr) if err != nil { @@ -674,7 +568,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { t.Fatalf("report output missing %q:\n%s", want, string(report)) } } - dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml") + 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) @@ -684,10 +578,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { !strings.Contains(string(dataPackage), "hourly_forecast:") { t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage)) } - rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json") - validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json") - renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json") - managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md") + 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", "*.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": {`) @@ -699,38 +593,24 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { } func TestRunInspectTodayArtifacts(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") - workspaceRoot := filepath.Join(tempDir, "workspace") - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{ "generate", "today", - "--config", configPath, + "--config", fixture.configPath, "--date", "2026-05-29", }, &stdout, &stderr) if err != nil { t.Fatalf("Run(generate) error = %v", err) } - dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob data package: %v", err) - } - if len(dataPackageMatches) != 1 { - t.Fatalf("data package files = %#v, want one", dataPackageMatches) - } - runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml") + dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") + runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml") stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) + 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) } @@ -741,7 +621,7 @@ func TestRunInspectTodayArtifacts(t *testing.T) { var sourcesOutput string for _, command := range []string{"metadata", "modules", "data-package", "sources"} { stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) + 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) } @@ -758,38 +638,24 @@ func TestRunInspectTodayArtifacts(t *testing.T) { } func TestRunInspectGeneratedArtifacts(t *testing.T) { - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - configPath := filepath.Join(tempDir, "config.yml") - workspaceRoot := filepath.Join(tempDir, "workspace") - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{ "generate", "daily", - "--config", configPath, + "--config", fixture.configPath, "--date", "2026-05-29", }, &stdout, &stderr) if err != nil { t.Fatalf("Run(generate) error = %v", err) } - dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) - if err != nil { - t.Fatalf("glob data package: %v", err) - } - if len(dataPackageMatches) != 1 { - t.Fatalf("data package files = %#v, want one", dataPackageMatches) - } - runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml") + dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") + runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml") stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) + 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) } @@ -800,7 +666,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) { var sourcesOutput string for _, command := range []string{"metadata", "modules", "data-package", "sources"} { stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) + 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) } @@ -818,11 +684,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) { func TestRunInspectMissingMetadata(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yml") - configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} @@ -838,11 +700,7 @@ func TestRunInspectMissingMetadata(t *testing.T) { func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yml") - configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) runner := Runner{Clock: fixedClock()} commands := []string{"metadata", "modules", "data-package", "prior", "sources"} @@ -936,10 +794,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) { func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) { runner := Runner{Clock: fixedClock()} - configPath := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } + 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 { @@ -1259,16 +1114,56 @@ func dailyServer(t *testing.T) *httptest.Server { return server } -func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string { +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") - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + 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 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...)...)) @@ -1281,6 +1176,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string { 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 assertFileContains(t *testing.T, path string, want string) { t.Helper() data, err := os.ReadFile(path)