v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4606
-449
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user