package tui import ( "context" "errors" "io" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" ) type brokenTerminalInput struct{} func (brokenTerminalInput) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } func TestProgramExitDrainsOwnedWorker(t *testing.T) { for _, test := range []struct { name string committed bool brokenInput bool setupFailure bool }{ {name: "cancel staging"}, {name: "cancel committed operation", committed: true}, {name: "terminal failure during commit", committed: true, brokenInput: true}, {name: "committed setup failure during shutdown", committed: true, setupFailure: true}, } { t.Run(test.name, func(t *testing.T) { sandboxConfigHome(t) m := NewModel() m.state, m.installing, m.installOperationType = StateInstalling, true, OpTypeUIChange m.selectedUIApp = &config.InstalledApp{ID: "selected-app", AppName: "MPV", InstallMethod: constants.MethodMPVBinary} m.releaseInfoLoader = func() (*installer.ReleaseInfo, error) { return nil, errors.New("offline fixture") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() workerCtx, cancelWorker := context.WithCancel(context.Background()) defer cancelWorker() output, result := make(chan string), make(chan error, 1) entered, release := make(chan struct{}), make(chan struct{}) defer close(release) guard := launchOperationWorker(workerCtx, output, result, func(ctx context.Context, cr *installer.CommandRunner) error { if test.committed { if err := cr.BeginCommit(); err != nil { return err } } close(entered) select { case <-release: case <-ctx.Done(): } output <- "worker terminal output" if test.setupFailure { return &installer.PartialInstallError{Err: errors.New("setup failed after commit")} } return ctx.Err() }) // Discard the returned command: ownership must already be registered. m.ownOperation(saveChannelsMsg{outputChan: output, installDone: result, cancel: cancelWorker, commitGuard: guard}) <-entered options := []tea.ProgramOption{tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutRenderer()} if test.brokenInput { options[0] = tea.WithInput(brokenTerminalInput{}) } done := make(chan error, 1) go func() { done <- m.Run(ctx, options...) }() if !test.brokenInput { cancel() } if test.committed { select { case err := <-done: t.Fatalf("program abandoned committed work: %v", err) case <-time.After(100 * time.Millisecond): } if workerCtx.Err() != nil { t.Fatal("committed worker was cancelled") } // Send one release without closing twice in cleanup. release <- struct{}{} } select { case err := <-done: if test.brokenInput { if err == nil { t.Fatal("terminal failure was lost") } } else if !errors.Is(err, context.Canceled) { t.Fatalf("lost cancellation: %v", err) } case <-time.After(5 * time.Second): t.Fatal("program did not drain its operation") } if !m.installComplete || m.operationActive { t.Fatal("operation was not finalized") } if !strings.Contains(strings.Join(m.installOutput, "\n"), "worker terminal output") { t.Fatal("trailing output lost") } path, err := jobHistoryFilePath() if err != nil { t.Fatal(err) } records, err := loadJobHistoryRecords(path) if err != nil || len(records) != 1 { t.Fatalf("terminal history not persisted: %v, %v", records, err) } want := "cancelled" if test.committed { want = "complete" } if test.setupFailure { want = "partial" } if records[0].Status != want { t.Fatalf("history status %q, want %q", records[0].Status, want) } }) } }