From f236a8086a4e106897c46e3a34736facdb47ac3e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 3 Jun 2026 11:43:50 +0000 Subject: [PATCH] Implement structured run reporting --- internal/app/run.go | 108 +++++++++----------------- internal/app/run_output.go | 148 ++++++++++++++++++++++++++---------- internal/app/run_summary.go | 10 ++- internal/app/run_test.go | 93 ++++++++++++++++++++++ internal/cli/root_test.go | 24 +++++- 5 files changed, 263 insertions(+), 120 deletions(-) diff --git a/internal/app/run.go b/internal/app/run.go index cc3e0ec..8edc2b2 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -47,83 +47,66 @@ func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error type backendFactoryProvider func(config.Environment) *backendFactory func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error { + report, err := buildRunReportWithBackendFactory(ctx, cfg, options, provider) + if err != nil && !IsPartialResultError(err) { + return err + } + if outputErr := WriteRunReport(options.Stdout, options.OutputFormat, report); outputErr != nil { + return outputErr + } + return err +} + +func buildRunReportWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) (RunReport, error) { notifier := options.Notifier if notifier == nil { notifier = notify.Noop{} } - jsonOutput := IsJSONOutput(options.OutputFormat) summary := runSummary{dryRun: options.DryRun} - result := runResult{ + report := RunReport{ DryRun: options.DryRun, - Pipelines: []runPipelineResult{}, - Actions: []runActionResult{}, + Pipelines: []RunPipelineSummary{}, + Actions: []RunActionRecord{}, } - var warnings []OutputWarning var failures runFailures secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil) if err != nil { - return err + return report, err } secretWarnings := secretConflictWarnings(secretLoad.Conflicts) - if jsonOutput { - warnings = append(warnings, secretWarnings...) - } else if options.Stdout != nil { - if err := writeWarnings(options.Stdout, secretWarnings); err != nil { - return err - } - } + report.PreambleWarnings = append(report.PreambleWarnings, secretWarnings...) + report.addWarnings(secretWarnings) backends := provider(secretLoad.Environment) backends.readOnlyKnownHosts = options.DryRun transforms := newTransformRegistry() - if options.Stdout != nil && !jsonOutput { - if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil { - return err - } - } for _, pipeline := range cfg.Pipelines { pipelineWarnings := sshWarnings(pipeline) - if jsonOutput { - warnings = append(warnings, pipelineWarnings...) - } else if options.Stdout != nil { - if err := writeWarnings(options.Stdout, pipelineWarnings); err != nil { - return err - } - } + report.addWarnings(pipelineWarnings) sourceBackend, err := backends.openSource(ctx, pipeline.Source) if err != nil { - return fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err) + return report, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err) } bundles, err := bundle.Discover(ctx, sourceBackend, "") if err != nil { closeBackend(sourceBackend) - return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err) + return report, fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err) } - result.Pipelines = append(result.Pipelines, runPipelineResult{ + report.Pipelines = append(report.Pipelines, RunPipelineSummary{ ID: pipeline.ID, SourceBackend: pipeline.Source.Backend, BundleCount: len(bundles), Destinations: destinationIDs(pipeline.Destinations), + Warnings: pipelineWarnings, }) - if options.Stdout != nil && !jsonOutput { - if _, err := fmt.Fprintf(options.Stdout, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.Source.Backend, len(bundles), destinationSummary(pipeline.Destinations)); err != nil { - closeBackend(sourceBackend) - return err - } - } + pipelineIndex := len(report.Pipelines) - 1 for _, destination := range pipeline.Destinations { selections := selectDestinationBundles(destination, bundles) if isFixedPathDestination(destination) { summary.recordFixedPath() if options.DryRun { warning := fixedPathSelectionWarning(pipeline.ID, destination.ID, selections, len(bundles)) - if jsonOutput { - warnings = append(warnings, warning) - } else if options.Stdout != nil { - if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil { - closeBackend(sourceBackend) - return err - } - } + report.addWarning(warning) + report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning)) } } if len(selections) == 0 { @@ -134,11 +117,8 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options for _, selection := range selections { failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(selection.SourceBundle.RootRelativePath), err) summary.recordFailure() - if jsonOutput { - result.Actions = append(result.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err)) - } else if options.Stdout != nil { - writeErrorLine(options.Stdout, selection.SourceBundle.RootRelativePath, destination.ID, destination.Backend, err) - } + report.Actions = append(report.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err)) + report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1)) } continue } @@ -189,22 +169,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options plan.PathMapping = config.PathMappingFixed if options.DryRun && isDestructiveFixedPathAction(plan.Action) { warning := fixedPathReplacementWarning(plan) - if jsonOutput { - warnings = append(warnings, warning) - } else if options.Stdout != nil { - if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil { - deferCloseDestination() - closeBackend(sourceBackend) - return err - } - } + report.addWarning(warning) + report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning)) } } - if jsonOutput { - result.Actions = append(result.Actions, runActionFromPlan(destination.Backend, plan, err)) - } else if options.Stdout != nil { - writePlanLine(options.Stdout, destination.Backend, plan, err) - } + report.Actions = append(report.Actions, runActionFromPlan(destination.Backend, plan, err)) + report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1)) if err != nil { failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err) summary.recordFailure() @@ -230,20 +200,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options } closeBackend(sourceBackend) } - result.Summary = summary.Result() - if jsonOutput { - if err := WriteJSONEnvelope(options.Stdout, "run", len(failures.items) == 0, warnings, result, failures.outputErrors()); err != nil { - return err - } - } else if options.Stdout != nil { - if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil { - return err - } - } + report.Summary = summary.Result() + report.OutputErrors = failures.outputErrors() if len(failures.items) > 0 { - return failures + return report, failures } - return nil + return report, nil } type closeableBackend interface { diff --git a/internal/app/run_output.go b/internal/app/run_output.go index 4c16c7c..1044083 100644 --- a/internal/app/run_output.go +++ b/internal/app/run_output.go @@ -10,61 +10,125 @@ import ( "gitea.maximumdirect.net/eric/distributor/internal/storage" ) -func writePlanLine(w io.Writer, backend string, plan publish.Plan, planErr error) { - if w == nil { - return +func WriteRunReport(w io.Writer, format OutputFormat, report RunReport) error { + if IsJSONOutput(format) { + return WriteJSONEnvelope(w, "run", len(report.OutputErrors) == 0, report.Warnings, report, report.OutputErrors) } - if planErr != nil { - destinationID := plan.DestinationID + return writeRunReportText(w, report) +} + +func writeRunReportText(w io.Writer, report RunReport) error { + if w == nil { + return nil + } + if err := writeWarnings(w, report.PreambleWarnings); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Configured pipelines: %d\n", len(report.Pipelines)); err != nil { + return err + } + for _, pipeline := range report.Pipelines { + if err := writeWarnings(w, pipeline.Warnings); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.SourceBackend, pipeline.BundleCount, destinationIDSummary(pipeline.Destinations)); err != nil { + return err + } + for _, event := range pipeline.events { + if event.warning != nil { + if err := writeWarnings(w, []OutputWarning{*event.warning}); err != nil { + return err + } + continue + } + if event.actionIndex < 0 || event.actionIndex >= len(report.Actions) { + continue + } + writeRunActionLine(w, report.Actions[event.actionIndex]) + } + } + _, err := fmt.Fprintln(w, report.Summary.Line()) + return err +} + +func writeRunActionLine(w io.Writer, action RunActionRecord) { + if action.Action == "error" { + destinationID := action.DestinationID if destinationID == "" { destinationID = "unknown" } - fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", storage.DisplayPath(plan.BundlePath), destinationID, backend, pathMappingSummary(plan), planErr.Error()) + fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", action.BundlePath, destinationID, action.Backend, pathMappingRecordSummary(action), action.Reason) return } - fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", storage.DisplayPath(plan.BundlePath), plan.DestinationID, backend, pathMappingSummary(plan), plan.Action, outputSummary(plan.Outputs), plan.Reason) + fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", action.BundlePath, action.DestinationID, action.Backend, pathMappingRecordSummary(action), action.Action, outputRecordSummary(action.Outputs), action.Reason) } -func pathMappingSummary(plan publish.Plan) string { - if plan.PathMapping != config.PathMappingFixed { +func pathMappingRecordSummary(action RunActionRecord) string { + if action.PathMapping != config.PathMappingFixed { return "" } - return fmt.Sprintf(" path_mapping=fixed target=%s", storage.DisplayPath(plan.DestinationBundlePath)) + return fmt.Sprintf(" path_mapping=fixed target=%s", action.DestinationPath) } -func writeErrorLine(w io.Writer, bundlePath, destinationID, backend string, err error) { - if w == nil { - return - } - fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s action=error reason=%q\n", storage.DisplayPath(bundlePath), destinationID, backend, err.Error()) -} - -func outputSummary(outputs []publish.Output) string { +func outputRecordSummary(outputs []RunOutputRecord) string { if len(outputs) == 0 { return "none" } paths := make([]string, 0, len(outputs)) for _, output := range outputs { - paths = append(paths, output.DestinationPath) + paths = append(paths, output.Path) } return strings.Join(paths, ",") } -type runResult struct { - DryRun bool `json:"dry_run"` - Pipelines []runPipelineResult `json:"pipelines"` - Actions []runActionResult `json:"actions"` - Summary runSummaryResult `json:"summary"` +func destinationIDSummary(ids []string) string { + if len(ids) == 0 { + return "none" + } + return strings.Join(ids, ",") } -type runPipelineResult struct { - ID string `json:"id"` - SourceBackend string `json:"source_backend"` - BundleCount int `json:"bundle_count"` - Destinations []string `json:"destinations"` +type RunReport struct { + DryRun bool `json:"dry_run"` + Pipelines []RunPipelineSummary `json:"pipelines"` + Actions []RunActionRecord `json:"actions"` + Summary RunSummaryCounters `json:"summary"` + Warnings []OutputWarning `json:"-"` + OutputErrors []OutputError `json:"-"` + PreambleWarnings []OutputWarning `json:"-"` } -type runActionResult struct { +func (r *RunReport) addWarning(warning OutputWarning) { + r.Warnings = append(r.Warnings, warning) +} + +func (r *RunReport) addWarnings(warnings []OutputWarning) { + r.Warnings = append(r.Warnings, warnings...) +} + +type RunPipelineSummary struct { + ID string `json:"id"` + SourceBackend string `json:"source_backend"` + BundleCount int `json:"bundle_count"` + Destinations []string `json:"destinations"` + Warnings []OutputWarning `json:"-"` + events []runPipelineEvent +} + +type runPipelineEvent struct { + warning *OutputWarning + actionIndex int +} + +func warningEvent(warning OutputWarning) runPipelineEvent { + return runPipelineEvent{warning: &warning, actionIndex: -1} +} + +func actionEvent(actionIndex int) runPipelineEvent { + return runPipelineEvent{actionIndex: actionIndex} +} + +type RunActionRecord struct { PipelineID string `json:"pipeline_id,omitempty"` DestinationID string `json:"destination_id"` Backend string `json:"backend"` @@ -75,10 +139,10 @@ type runActionResult struct { Action string `json:"action"` PrimaryURL string `json:"primary_url,omitempty"` Reason string `json:"reason,omitempty"` - Outputs []runOutputResult `json:"outputs"` + Outputs []RunOutputRecord `json:"outputs"` } -type runOutputResult struct { +type RunOutputRecord struct { Path string `json:"path"` Kind string `json:"kind"` SourcePath string `json:"source_path,omitempty"` @@ -88,13 +152,13 @@ type runOutputResult struct { Size int64 `json:"size"` } -func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActionResult { +func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActionRecord { if planErr != nil { destinationID := plan.DestinationID if destinationID == "" { destinationID = "unknown" } - return runActionResult{ + return RunActionRecord{ PipelineID: plan.PipelineID, DestinationID: destinationID, Backend: backend, @@ -105,10 +169,10 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi Action: "error", PrimaryURL: plan.PrimaryURL, Reason: planErr.Error(), - Outputs: []runOutputResult{}, + Outputs: []RunOutputRecord{}, } } - return runActionResult{ + return RunActionRecord{ PipelineID: plan.PipelineID, DestinationID: plan.DestinationID, Backend: backend, @@ -123,8 +187,8 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi } } -func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) runActionResult { - return runActionResult{ +func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) RunActionRecord { + return RunActionRecord{ PipelineID: pipelineID, DestinationID: destinationID, Backend: backend, @@ -132,15 +196,15 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro DestinationPath: storage.DisplayPath(bundlePath), Action: "error", Reason: err.Error(), - Outputs: []runOutputResult{}, + Outputs: []RunOutputRecord{}, } } -func runOutputsFromPlan(outputs []publish.Output) []runOutputResult { - results := make([]runOutputResult, 0, len(outputs)) +func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord { + results := make([]RunOutputRecord, 0, len(outputs)) for _, output := range outputs { stateOutput := output.StateOutputFile() - results = append(results, runOutputResult{ + results = append(results, RunOutputRecord{ Path: stateOutput.Path, Kind: stateOutput.Kind, SourcePath: stateOutput.SourcePath, diff --git a/internal/app/run_summary.go b/internal/app/run_summary.go index 41e9c61..6f4c111 100644 --- a/internal/app/run_summary.go +++ b/internal/app/run_summary.go @@ -47,7 +47,7 @@ func (s runSummary) Line() string { return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun, s.fixedPath) } -type runSummaryResult struct { +type RunSummaryCounters struct { Status string `json:"status"` Planned int `json:"planned"` PublishNew int `json:"publish_new"` @@ -59,12 +59,16 @@ type runSummaryResult struct { FixedPath int `json:"fixed_path"` } -func (s runSummary) Result() runSummaryResult { +func (s RunSummaryCounters) Line() string { + return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath) +} + +func (s runSummary) Result() RunSummaryCounters { status := "ok" if s.failures > 0 { status = "failed" } - return runSummaryResult{ + return RunSummaryCounters{ Status: status, Planned: s.planned, PublishNew: s.publishNew, diff --git a/internal/app/run_test.go b/internal/app/run_test.go index 4373c7b..5caa740 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -718,6 +718,99 @@ func TestRunJSONIncludesGeneratedOutputMetadata(t *testing.T) { } } +func TestBuildRunReportIncludesStructuredDryRunResults(t *testing.T) { + sourceRoot := t.TempDir() + destinationRoot := t.TempDir() + writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) + configPath := testutil.WriteLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex) + cfg, err := config.LoadFile(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + + report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{DryRun: true}, newBackendFactoryWithEnvironment) + if err != nil { + t.Fatalf("buildRunReportWithBackendFactory() error = %v", err) + } + if !report.DryRun || report.Summary.Status != "ok" || !report.Summary.DryRun { + t.Fatalf("report dry-run/status = dry_run:%t summary:%#v, want ok dry-run", report.DryRun, report.Summary) + } + if got, want := len(report.Pipelines), 1; got != want { + t.Fatalf("pipeline count = %d, want %d", got, want) + } + pipeline := report.Pipelines[0] + if pipeline.ID != "reports" || pipeline.SourceBackend != config.BackendLocal || pipeline.BundleCount != 1 || strings.Join(pipeline.Destinations, ",") != "archive" { + t.Fatalf("pipeline summary = %#v, want reports/local bundle summary", pipeline) + } + if got, want := len(report.Warnings), 1; got != want { + t.Fatalf("warning count = %d, want %d", got, want) + } + if !strings.Contains(report.Warnings[0].Message, "path_mapping=fixed candidates=1 selected_bundle=.") { + t.Fatalf("warning = %#v, want fixed path selection", report.Warnings[0]) + } + if got, want := len(report.Actions), 1; got != want { + t.Fatalf("action count = %d, want %d", got, want) + } + action := report.Actions[0] + if action.PipelineID != "reports" || action.DestinationID != "archive" || action.Action != "publish_new" || action.PrimaryURL != "https://reports.example.com/latest/" { + t.Fatalf("action = %#v, want publish_new with primary URL", action) + } + if action.PathMapping != config.PathMappingFixed || action.DestinationPath != "." { + t.Fatalf("action path mapping = %q destination path = %q, want fixed root", action.PathMapping, action.DestinationPath) + } + if got, want := len(action.Outputs), 1; got != want { + t.Fatalf("output count = %d, want %d", got, want) + } + output := action.Outputs[0] + if output.Path != "index.html" || output.Kind != state.OutputKindGenerated || output.SourcePath != "report.md" || output.Transform != "markdown_to_html" || output.URL != "https://reports.example.com/latest/" { + t.Fatalf("output = %#v, want generated index metadata", output) + } + if report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.FixedPath != 1 || report.Summary.Failed != 0 { + t.Fatalf("summary = %#v, want publish_new fixed path counters", report.Summary) + } + if len(report.OutputErrors) != 0 { + t.Fatalf("output errors = %#v, want none", report.OutputErrors) + } +} + +func TestBuildRunReportIncludesPartialFailures(t *testing.T) { + sourceRoot := t.TempDir() + firstDestination := t.TempDir() + secondDestination := t.TempDir() + writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) + if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil { + t.Fatalf("write unmanaged file: %v", err) + } + cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination)) + if err != nil { + t.Fatalf("load config: %v", err) + } + + report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{}, newBackendFactoryWithEnvironment) + if err == nil || !IsPartialResultError(err) { + t.Fatalf("buildRunReportWithBackendFactory() error = %v, want partial result error", err) + } + if report.Summary.Status != "failed" || report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.Failed != 1 { + t.Fatalf("summary = %#v, want one planned publish and one failure", report.Summary) + } + if got, want := len(report.Actions), 2; got != want { + t.Fatalf("action count = %d, want %d", got, want) + } + if report.Actions[0].DestinationID != "archive-one" || report.Actions[0].Action != "error" || !strings.Contains(report.Actions[0].Reason, "fail_unmanaged") { + t.Fatalf("first action = %#v, want archive-one error", report.Actions[0]) + } + if report.Actions[1].DestinationID != "archive-two" || report.Actions[1].Action != "publish_new" { + t.Fatalf("second action = %#v, want archive-two publish_new", report.Actions[1]) + } + if got, want := len(report.OutputErrors), 1; got != want { + t.Fatalf("output error count = %d, want %d", got, want) + } + outputError := report.OutputErrors[0] + if outputError.PipelineID != "reports" || outputError.DestinationID != "archive-one" || outputError.Backend != config.BackendLocal || outputError.BundlePath != "." || !strings.Contains(outputError.Message, "fail_unmanaged") { + t.Fatalf("output error = %#v, want archive-one unmanaged failure", outputError) + } +} + func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) { sourceRoot := t.TempDir() destinationRoot := t.TempDir() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index a646c04..98c2157 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -603,8 +603,12 @@ func TestExecuteRunDryRun(t *testing.T) { if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } - if !strings.Contains(stdout.String(), "action=publish_new") { - t.Fatalf("stdout = %q, want config summary", stdout.String()) + wantStdout := "Configured pipelines: 1\n" + + "- pipeline=reports source=local bundles=1 destinations=archive\n" + + " - bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt reason=\"destination state is absent\"\n" + + "Final status: ok planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\n" + if got := stdout.String(); got != wantStdout { + t.Fatalf("stdout = %q, want %q", got, wantStdout) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -631,6 +635,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) { if result["dry_run"] != true { t.Fatalf("result = %#v, want dry_run true", result) } + pipelines, ok := result["pipelines"].([]any) + if !ok || len(pipelines) != 1 { + t.Fatalf("pipelines = %#v, want one pipeline", result["pipelines"]) + } + pipeline, ok := pipelines[0].(map[string]any) + if !ok || pipeline["id"] != "reports" || pipeline["source_backend"] != "local" || pipeline["bundle_count"] != float64(1) { + t.Fatalf("pipeline = %#v, want reports/local summary", pipelines[0]) + } actions, ok := result["actions"].([]any) if !ok || len(actions) != 1 { t.Fatalf("actions = %#v, want one action", result["actions"]) @@ -639,6 +651,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) { if !ok || action["action"] != "publish_new" { t.Fatalf("action = %#v, want publish_new", actions[0]) } + outputs, ok := action["outputs"].([]any) + if !ok || len(outputs) != 2 { + t.Fatalf("outputs = %#v, want source outputs", action["outputs"]) + } + summary, ok := result["summary"].(map[string]any) + if !ok || summary["status"] != "ok" || summary["planned"] != float64(1) || summary["publish_new"] != float64(1) || summary["dry_run"] != true { + t.Fatalf("summary = %#v, want ok dry-run publish counters", result["summary"]) + } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) }