v0.33.0 tui tap for live view and console enhancements

This commit is contained in:
lixen committed 2026-10-10 21:32:08 -04:00
1 parent a1e9d21d06
commit 87f5576ccf
41 files changed
+4606 -449

No files matched your search

+363
View File
@@ -0,0 +1,363 @@
package tui
import (
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/terminal"
)
// fakeRunner keeps the screen's calls; start, when set, answers Start
type fakeRunner struct {
mu sync.Mutex
specs [][]config.Spec
stops int
state RunState
start func(ask Ask) (*core.Tap, error)
}
func (f *fakeRunner) Start(specs []config.Spec, ask Ask) (*core.Tap, error) {
f.mu.Lock()
f.specs = append(f.specs, specs)
start := f.start
f.mu.Unlock()
if start != nil {
return start(ask)
}
return core.NewTap(1), nil
}
func (f *fakeRunner) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
f.stops++
}
func (f *fakeRunner) State() RunState {
f.mu.Lock()
defer f.mu.Unlock()
return f.state
}
func (f *fakeRunner) calls() (starts, stops int) {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.specs), f.stops
}
// running is a screen on c whose runner is f, already running c, back on
// the diagram from the view t opened
func running(t *testing.T, c *compose.Composition, f *fakeRunner) *app {
t.Helper()
a := mono(c)
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
if a.live.specs == nil || !a.viewing {
t.Fatalf("t did not run, viewing %v: %T %s", a.viewing, a.dialog, a.status)
}
press(a, "t")
return a
}
// await ticks once the runner's side brought something back, as the woken
// screen would
func await(t *testing.T, a *app) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) {
a.live.mu.Lock()
n := len(a.live.later)
a.live.mu.Unlock()
if n > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("nothing came back from the runner")
}
}
press(a, terminal.KeyNone)
}
// The tick is handled before any key: it clears no status and types nothing
// into the field being edited
func TestATickKeepsTheStatusAndTheField(t *testing.T) {
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
press(a, terminal.KeyEnter, terminal.KeyEnter, "ab")
a.status = "refused"
press(a, terminal.KeyNone)
if a.status != "refused" || a.insp.field == nil || a.insp.field.Value() != "ab" {
t.Fatalf("status %q, field %v", a.status, a.insp.field)
}
}
// Wakes coalesce: one tick waits at a time, and handling it allows the next
func TestWakesCoalesce(t *testing.T) {
posts := 0
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(nil, func() { posts++ })
a.live.wake()
a.live.wake()
if press(a, terminal.KeyNone); posts != 1 {
t.Fatalf("%d ticks posted for two wakes", posts)
}
if a.live.wake(); posts != 2 {
t.Fatal("a handled tick kept the next wake from posting")
}
}
// The state is read on, but the screen is woken only by a change
func TestTheStateWakesTheScreenOnlyWhenItChanges(t *testing.T) {
var posts sync.WaitGroup
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 1}}}}
r := newRun(f, func() { posts.Done() })
posts.Add(1)
go r.watch(time.Millisecond)
defer r.endAsks()
posts.Wait()
time.Sleep(20 * time.Millisecond)
r.posted.Store(false)
time.Sleep(20 * time.Millisecond)
if r.posted.Load() {
t.Fatal("an unchanged state woke the screen")
}
posts.Add(1)
f.mu.Lock()
f.state = RunState{Counts: map[string]Counts{"p": {In: 2}}}
f.mu.Unlock()
posts.Wait()
}
// t runs only a valid composition
func TestTapRefusesAnInvalidComposition(t *testing.T) {
f := &fakeRunner{}
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
a.live = newRun(f, func() {})
if press(a, "t"); a.status != "not valid yet: ! shows the problem" || a.live.op != "" {
t.Fatalf("status %q, op %q", a.status, a.live.op)
}
if starts, _ := f.calls(); starts != 0 {
t.Fatal("started an invalid composition")
}
}
// t runs the pipelines and marks the bar; unchanged, t only opens the view,
// and after an edit it runs the edited pipelines
func TestTapRerunsAfterAnEdit(t *testing.T) {
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 7, Filtered: 2}}}}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
a.live.state = f.State()
first := config.PipelineSpecs(a.comp.Pipelines)
if top := text(render(a, 100, 24), 100)[0]; !strings.Contains(top, "running · 7 in · 2 filtered") || !slices.Equal(f.specs[0], first) {
t.Fatalf("%q, specs %v", top, f.specs)
}
if press(a, "t"); a.live.op != "" || !a.viewing {
t.Fatalf("t on what runs: op %q, viewing %v", a.live.op, a.viewing)
}
press(a, "t")
_, err := a.comp.Add(0, "sink", "console")
a.edit(err)
press(a, "t")
await(t, a)
if starts, _ := f.calls(); starts != 2 || slices.Equal(f.specs[1], first) || !slices.Equal(a.live.specs, f.specs[1]) {
t.Fatalf("rerun: %d starts, running %v", starts, a.live.specs)
}
}
// A rerun that does not build leaves the earlier pipelines running, and says
// so; one that fails once they stopped leaves nothing running
func TestAFailedRerunSaysWhatRuns(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
earlier, tap := a.live.specs, a.live.tap
notice := func() string { return strings.Join(text(render(a, 120, 30), 120), "\n") }
f.start = func(Ask) (*core.Tap, error) { return tap, errors.New("bad sink") }
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
press(a, "t")
await(t, a)
if !slices.Equal(a.live.specs, earlier) || a.live.tap != tap || !strings.Contains(notice(), "not rerun: bad sink; the earlier pipelines still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
f.start = func(Ask) (*core.Tap, error) { return nil, errors.New("port taken") }
press(a, terminal.KeyEscape, "t")
await(t, a)
if a.live.specs != nil || a.live.tap != nil || strings.Contains(notice(), "still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
}
// A start error chooses the part its path names, as a problem's does, and
// shows its text
func TestAStartErrorChoosesItsPart(t *testing.T) {
why := config.At(compose.Node{Role: "sink", ID: "console"}.Path(0), errors.New("failed to start sink console: write: broken pipe"))
f := &fakeRunner{start: func(Ask) (*core.Tap, error) { return nil, why }}
a := mono(pipeline(t, []string{"null"}, []string{"null", "console"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
n, _ := a.node()
if screen := strings.Join(text(render(a, 120, 30), 120), " "); n != (compose.Node{Role: "sink", ID: "console"}) || !strings.Contains(screen, "sink console: write") {
t.Fatalf("on %+v\n%s", n, screen)
}
}
// q asks before it stops what runs, and quits once it stopped
func TestQuitAsksWhileRunning(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
press(a, "q")
if c, asks := a.dialog.(*confirm); !asks || !strings.Contains(c.question, "Stop the pipelines") {
t.Fatalf("q while running: %T", a.dialog)
}
press(a, "y")
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("done %v after %d stops", a.done, stops)
}
}
// r and o's choices stop the run before the screen leaves, and take no key
// meanwhile
func TestLeavingStopsTheRunFirst(t *testing.T) {
for _, keys := range []string{"r", "oc"} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
if press(a, keys, "q"); a.done || a.dialog != nil {
t.Fatalf("%s left before stopping: %T", keys, a.dialog)
}
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 || a.result.Exit == Quit {
t.Fatalf("%s: done %v after %d stops, %+v", keys, a.done, stops, a.result)
}
}
}
// A password a terminal would be asked for is asked in a masked form; with
// generate, Enter alone draws one, shown once, and that is the answer
func TestPasswordsAreAskedMasked(t *testing.T) {
for _, generate := range []bool{false, true} {
var got []byte
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
pw, err := ask("alice", generate)
got = pw
return core.NewTap(1), err
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
typed := "s3cret"
if generate {
typed = ""
}
press(a, typed)
if screen := strings.Join(text(render(a, 100, 30), 100), "\n"); !strings.Contains(screen, "Password for alice") ||
strings.Contains(screen, "s3cret") || !strings.Contains(screen, strings.Repeat("*", len(typed))) {
t.Fatalf("the form:\n%s", screen)
}
press(a, terminal.KeyEnter)
shown := strings.Join(text(render(a, 100, 30), 100), " ")
if generate {
press(a, terminal.KeyEnter) // read
}
await(t, a)
if a.live.specs == nil || len(got) == 0 || !generate && string(got) != typed || generate && !strings.Contains(shown, "shown this once: "+string(got)) {
t.Fatalf("generate %v: answered %q\n%s", generate, got, shown)
}
}
}
// A generated password stays on screen until a key closes its notice: the
// start goes on only then, so neither a start error nor the next ask hides it
func TestAGeneratedPasswordStaysUntilRead(t *testing.T) {
for name, then := range map[string]func(Ask) error{
"a start error": func(Ask) error { return errors.New("port taken") },
"the next ask": func(ask Ask) error { _, err := ask("bob", true); return err },
} {
answered := make(chan struct{})
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
if _, err := ask("alice", true); err != nil {
return nil, err
}
close(answered)
return core.NewTap(1), then(ask)
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
press(a, terminal.KeyEnter)
select {
case <-answered:
t.Fatalf("%s: answered before the password was read", name)
case <-time.After(50 * time.Millisecond):
}
if press(a, terminal.KeyNone); !strings.Contains(strings.Join(text(render(a, 100, 30), 100), " "), "shown this once") {
t.Fatalf("%s: the password left the screen unread: %T", name, a.dialog)
}
press(a, "x")
<-answered
await(t, a)
if screen := strings.Join(text(render(a, 100, 30), 100), " "); strings.Contains(screen, "shown this once") {
t.Fatalf("%s: the notice outlived its key\n%s", name, screen)
}
a.live.close()
}
}
// On screen a closed stdout fails the write with EPIPE instead of killing lw
// with the terminal raw; leaving gives SIGPIPE back to the runtime
func TestAClosedStdoutFailsWritesOnScreen(t *testing.T) {
if os.Getenv("LW_TEST_CLOSED_STDOUT") != "" {
stop := newRun(nil, func() {}).onSignals()
_, err := os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "on screen:", err)
stop()
os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "lived on")
os.Exit(0)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
r.Close()
defer w.Close()
var stderr strings.Builder
cmd := exec.Command(os.Args[0], "-test.run=^TestAClosedStdoutFailsWritesOnScreen$")
cmd.Env, cmd.Stdout, cmd.Stderr = append(os.Environ(), "LW_TEST_CLOSED_STDOUT=1"), w, &stderr
cmd.Run()
ws, _ := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !strings.Contains(stderr.String(), "on screen: write /dev/stdout: broken pipe") || !ws.Signaled() || ws.Signal() != syscall.SIGPIPE {
t.Fatalf("%v\n%s", ws, stderr.String())
}
}
// SIGTERM and SIGHUP stop what runs and leave
func TestSignalsStopTheRunAndLeave(t *testing.T) {
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGHUP} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
stop := a.live.onSignals()
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatal(err)
}
await(t, a)
await(t, a)
stop()
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("%v: done %v after %d stops", sig, a.done, stops)
}
}
}