From f88b68aea18062cc0d3fa1c6c3da961793b9bcc1 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:49:47 +0800 Subject: [PATCH] fix(tui): exit 1 when the results file is not written (#155) reportOutputErr only added a message to the view, so exitCode still returned 0 after a failed write and scripts checking $? treated a truncated or missing results file as success. Track the failure on the model and fail the run, matching the CLI, where a file error outranks everything else. --- internal/tui/model.go | 15 +++++++++++---- internal/tui/model_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index f0ab46a..3145b8e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -43,6 +43,10 @@ type Model struct { vals formValues // the running scan's settings, for its report seed uint64 started time.Time + + // outputErr records that the results file could not be written, so the + // process exits non-zero even when the scan itself succeeded (#155). + outputErr bool } // Options configure the TUI. @@ -65,8 +69,9 @@ func New(opts Options) Model { } } -// Start runs the TUI and returns an exit code: 1 if the TUI itself failed or -// the last scan failed (#55), else 0. +// Start runs the TUI and returns an exit code: 1 if the TUI itself failed, +// the last scan failed (#55), or the results file could not be written (#155), +// else 0. func Start(opts Options) int { p := tea.NewProgram(New(opts), tea.WithAltScreen()) final, err := p.Run() @@ -78,7 +83,7 @@ func Start(opts Options) int { // exitCode maps the final model to the process exit code. func exitCode(final tea.Model) int { - if m, ok := final.(Model); ok && m.scanView.failed() { + if m, ok := final.(Model); ok && (m.scanView.failed() || m.outputErr) { return 1 } return 0 @@ -331,9 +336,11 @@ func (m *Model) summary(done doneMsg) output.Summary { return s } -// reportOutputErr surfaces an output-file failure in the scan view. +// reportOutputErr surfaces an output-file failure in the scan view and makes +// it fail the run, as it does on the command line (#155). func (m *Model) reportOutputErr(err error) { if err != nil { + m.outputErr = true m.scanView.addMessage(errorStyle.Render("✗ output file incomplete: " + err.Error())) } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index aba8333..816c088 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -121,6 +121,35 @@ func TestFinalizeOutputReportsCloseError(t *testing.T) { } } +// TestOutputFileFailureSetsExitCode covers #155: when the results file cannot +// be written, the TUI exits 1 like the CLI does, even though the scan itself +// succeeded. +func TestOutputFileFailureSetsExitCode(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.txt") + f, err := output.CreateFile(path) + if err != nil { + t.Fatal(err) + } + m := Model{out: output.NewFile(f.Writer, false, output.FormatText), outFile: f} + m.out.Result("a.example.com", nil) + // Put a non-empty directory where the results file goes, so the final + // rename into place fails. + if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o755); err != nil { + t.Fatal(err) + } + m.reportOutputErr(m.finalizeOutput(true)) + if code := exitCode(m); code != 1 { + t.Fatalf("exit code = %d, want 1 after an output-file failure", code) + } + if !strings.Contains(m.scanView.View(), "output file incomplete") { + t.Errorf("scan view does not report the incomplete results file: %s", m.scanView.View()) + } + // A run without an output-file failure still exits 0. + if code := exitCode(Model{}); code != 0 { + t.Errorf("exit code = %d, want 0 without an output-file failure", code) + } +} + // TestValidateRejectsStdinWordlist covers #55: "-" would read the stdin that // Bubble Tea owns and freeze the UI. func TestValidateRejectsStdinWordlist(t *testing.T) {