364 lines
12 KiB
Go
364 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|