// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later //go:build linux && cgo package terminal import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "strings" "syscall" "testing" "time" "unsafe" "git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/prompt" "git.stormux.org/storm/fenrir/internal/reader" ) // Live server addresses and acceptance of the server's connection license are // explicit opt-ins. Chat is a private message to this fixture's own account. // The required preload clocks/zeros ALSA null capture; no real audio is used. func TestNinjamLiveOverride(t *testing.T) { host, port := os.Getenv("FENRIR_NINJAM_TEST_HOST"), os.Getenv("FENRIR_NINJAM_TEST_PORT") preload := os.Getenv("FENRIR_NINJAM_NULL_AUDIO") if host == "" || port == "" || preload == "" || os.Getenv("FENRIR_NINJAM_TEST_LICENSE") != "1" { t.Skip("set FENRIR_NINJAM_TEST_HOST/PORT, FENRIR_NINJAM_NULL_AUDIO and FENRIR_NINJAM_TEST_LICENSE=1 for an authorized live server test") } program, err := exec.LookPath("cninjam") if err != nil { t.Fatal(err) } root := os.Getenv("FENRIR_NINJAM_TEST_ARTIFACTS") if root == "" { root, err = os.MkdirTemp("", "fenrir-ninjam-live-") if err != nil { t.Fatal(err) } } if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } t.Log("Retained live-test artifacts:", root) // Every invocation gets a distinct private client directory and identity. clientDir, err := os.MkdirTemp(root, "client-") if err != nil { t.Fatal(err) } username := fmt.Sprintf("Username%04x", time.Now().UnixNano()&0xffff) configuration := "master mastervol 1 masterpan 0 metrovol 0 metropan 0 mastermute 0 metromute 1\nlocal 0 source 0 bc 0 mute 1 volume 0 pan 0 name \"Fixture Channel\"\n" if err := os.WriteFile(filepath.Join(clientDir, "ninjam.config"), []byte(configuration), 0600); err != nil { t.Fatal(err) } raw, err := os.OpenFile(filepath.Join(clientDir, "terminal.ansi"), os.O_CREATE|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } defer raw.Close() trace, err := os.OpenFile(filepath.Join(clientDir, "presentation.jsonl"), os.O_CREATE|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } defer trace.Close() encoder := json.NewEncoder(trace) master, slave, err := open_pty(24, 100) if err != nil { t.Fatal(err) } defer syscall.Close(master) slaveFile := os.NewFile(uintptr(slave), "private cninjam PTY") defer slaveFile.Close() args := []string{"-c", `"$@"; result=$?; printf '%s' '[Username@host test] $ '; exit "$result"`, "fixture", program, host + ":" + port, "-user", "anonymous:" + username, "-alsaconfig", "in null out null srate 48000 nch 2 bps 16 bsize 2048 nblock 4", "-nosavesourcefiles", "-nosavelocal", "-nowritelog", "-sessiondir", filepath.Join(clientDir, "session")} child := exec.Command("/bin/sh", args...) child.Dir = clientDir child.Env = append(os.Environ(), "HOME="+clientDir, "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "ALSA_CONFIG_PATH=/usr/share/alsa/alsa.conf", "LD_PRELOAD="+preload) child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} if err := child.Start(); err != nil { t.Fatal(err) } defer func() { _ = syscall.Kill(-child.Process.Pid, syscall.SIGTERM); _ = child.Wait() }() if err := syscall.SetNonblock(master, true); err != nil { t.Fatal(err) } emulator, err := new_emulator(24, 100) if err != nil { t.Fatal(err) } defer emulator.close() engine := reader.New() var adapter *ninjam.Adapter var output []string peerPump := func() {} pump := func() { peerPump() var buffer [32768]byte for total := 0; total < 131072; { count, err := syscall.Read(master, buffer[:]) if count > 0 { total += count emulator.feed(buffer[:count]) if _, err := raw.Write(buffer[:count]); err != nil { t.Fatal(err) } } if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EIO) || count == 0 { break } if err != nil && !errors.Is(err, syscall.EINTR) { t.Fatal(err) } } snapshot := emulator.snapshot(true, false) if adapter != nil { frame := adapter.Observe(snapshot) for _, message := range engine.Update_application(snapshot, frame) { if message.Text != "" { output = append(output, message.Text) if err := encoder.Encode(map[string]any{"at": time.Now().UTC(), "text": message.Text}); err != nil { t.Fatal(err) } } } } } screenText := func() string { snapshot := emulator.snapshot(true, false) var lines []string for row := range snapshot.Rows { lines = append(lines, snapshot.Text(row)) } return strings.Join(lines, "\n") } saveScreen := func(name string) { if err := os.WriteFile(filepath.Join(clientDir, name+".txt"), []byte(screenText()), 0600); err != nil { t.Fatal(err) } } defer saveScreen("final-screen") waitFor := func(label string, timeout time.Duration, predicate func() bool) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { pump() if predicate() { return } time.Sleep(10 * time.Millisecond) } saveScreen("failure-screen") t.Fatalf("%s not observed; see retained screen and presentation trace", label) } write := func(data string) { t.Helper() if _, err := syscall.Write(master, []byte(data)); err != nil { t.Fatal(err) } } focus := func() string { if adapter == nil { return "" } result, _ := adapter.Command(command.ReadLiveRegion2) return result.Text } waitFor("server license or connection", 12*time.Second, func() bool { return strings.Contains(screenText(), "Hit Y to agree") || strings.Contains(screenText(), "Use the arrows or pagedown to scroll down") }) for page := 0; page < 20 && !strings.Contains(screenText(), "Hit Y to agree"); page++ { write("\x1b[6~") deadline := time.Now().Add(100 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } } saveScreen("license") if !strings.Contains(screenText(), "Hit Y to agree") { t.Fatal("server license could not be fully displayed") } write("y") waitFor("connected main view", 12*time.Second, func() bool { return strings.Contains(screenText(), "[QUIT NINJAM]") && !strings.Contains(screenText(), ": 0.0BPM") }) saveScreen("connected-screen") adapter = &ninjam.Adapter{} engine.Configure_tracking("highlight") pump() if focus() != "Master unmuted" { t.Fatal("initial master control:", focus()) } send := func(data string) { engine.Note_input(true, false, false); write(data) } send("\r") waitFor("master muted", 2*time.Second, func() bool { return focus() == "Master muted" }) send("\r") waitFor("master unmuted", 2*time.Second, func() bool { return focus() == "Master unmuted" }) engine.Command(command.CurrentLine) start := len(output) send("\x1bOC") waitFor("volume focus after manual review", 2*time.Second, func() bool { return strings.Contains(focus(), "Master Volume and pan") && strings.Contains(strings.Join(output[start:], "\n"), focus()) }) start = len(output) send("\r") waitFor("volume editing instructions", 2*time.Second, func() bool { return strings.Contains(strings.Join(output[start:], "\n"), "USE UP AND DOWN FOR VOLUME") }) oldValue := focus() start = len(output) send("\x1bOA") waitFor("edited volume", 2*time.Second, func() bool { return focus() != oldValue && strings.Contains(focus(), "Master Volume and pan") && slices.Contains(output[start:], "plus 0.5dB") }) oldValue = focus() start = len(output) send("\x1bOD") waitFor("edited pan", 2*time.Second, func() bool { return focus() != oldValue && strings.Contains(focus(), "Master Volume and pan") && slices.Contains(output[start:], "1 percent left") }) send("\r") start = len(output) deadline := time.Now().Add(1300 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } for _, text := range output[start:] { if strings.Contains(text, "###") || strings.Contains(text, "<-120.0dB>") { t.Fatal("meter chatter:", text) } } send("\x1bOB") waitFor("local channel", 2*time.Second, func() bool { return strings.Contains(focus(), "Local channel Fixture Channel") }) send("\x1bOD") waitFor("local channel name", 2*time.Second, func() bool { return strings.Contains(focus(), "Channel name Fixture Channel") }) send("\r") waitFor("rename prompt", 2*time.Second, func() bool { return focus() == "Rename channel" }) write("Fixture Renamed\r") waitFor("renamed local channel", 2*time.Second, func() bool { return strings.Contains(focus(), "Channel name Fixture Renamed") }) send("\x1bOC") waitFor("local transmit control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " not transmitting") }) send("\x1bOC") waitFor("local input control", 2*time.Second, func() bool { return strings.Contains(focus(), " Input ") }) start = len(output) send("\r") waitFor("input selection prompt", 2*time.Second, func() bool { return strings.Contains(strings.Join(output[start:], "\n"), "USE ARROW KEYS TO SELECT THE INPUT CHANNEL") }) oldValue = focus() send("\x1bOB") waitFor("changed input selection", 2*time.Second, func() bool { return focus() != oldValue && strings.Contains(focus(), " Input ") }) send("\r") send("\x1bOC") waitFor("local mute control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") }) send("\r") waitFor("local unmuted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") }) send("\r") waitFor("local muted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") }) if os.Getenv("FENRIR_NINJAM_TEST_PEER") == "1" { peerStep, peerFocus, peerWrite := start_ninjam_live_peer(t, program, host+":"+port, preload, root) peerPump = peerStep waitFor("silent test peer channel", 12*time.Second, func() bool { return strings.Contains(screenText(), "Peer Fixture") }) for count := 0; count < 64 && !strings.Contains(focus(), "channel Peer Fixture"); count++ { send("\x1bOB") deadline := time.Now().Add(60 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } } if !strings.Contains(focus(), "channel Peer Fixture") { t.Fatal("remote test channel not reached") } for count := 0; count < 3; count++ { send("\x1bOD") deadline := time.Now().Add(30 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } } if !strings.HasSuffix(focus(), " receiving") { t.Fatal("remote receive control", focus()) } send("\r") waitFor("remote not receiving", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " not receiving") }) send("\r") waitFor("remote receiving", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " receiving") && !strings.HasSuffix(focus(), " not receiving") }) send("\x1bOC") waitFor("remote mute control", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") }) send("\r") waitFor("remote muted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " muted") && !strings.HasSuffix(focus(), " unmuted") }) send("\r") waitFor("remote unmuted", 2*time.Second, func() bool { return strings.HasSuffix(focus(), " unmuted") }) for count := 0; count < 64 && peerFocus() != "Chat message"; count++ { peerWrite("\x1bOB") deadline := time.Now().Add(60 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } } if peerFocus() != "Chat message" { t.Fatal("peer chat input not reached") } engine.Command(command.CurrentLine) marker := fmt.Sprintf("Fenrir-peer-test-%x", time.Now().UnixNano()&0xffff) start = len(output) peerWrite("/msg " + username + " " + marker + "\r") waitFor("passive incoming peer chat", 5*time.Second, func() bool { return strings.Contains(strings.Join(output[start:], "\n"), marker) }) if report := engine.Command(command.CursorLine); !strings.Contains(report.After.Text, "review cursor") { t.Fatal("passive chat ended manual review", report) } result, _ := adapter.Command(command.ReadLiveRegion1) if !strings.Contains(result.Text, marker) { t.Fatal("peer chat missing from recall") } saveScreen("remote-peer-screen") t.Log("PASS: remote receive/mute states and private peer chat during manual review; test peer transmits only clocked zeros") } // Match both the actual PTY and libvterm geometry. cninjam receives SIGWINCH // from the kernel, then redraws its own dynamically positioned chat area. dimensions := struct{ Rows, Cols, X, Y uint16 }{Rows: 30, Cols: 120} if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(master), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(&dimensions))); errno != 0 { t.Fatal(errno) } emulator.resize(30, 120) waitFor("resized application regions", 3*time.Second, func() bool { result, _ := adapter.Command(command.ReadLiveRegion3) return strings.Contains(result.Text, "of 120") && strings.Contains(emulator.snapshot(true, false).Text(29), "[QUIT NINJAM]") }) saveScreen("resized-screen") for count := 0; count < 64 && focus() != "Chat message"; count++ { send("\x1bOB") deadline := time.Now().Add(60 * time.Millisecond) for time.Now().Before(deadline) { pump() time.Sleep(10 * time.Millisecond) } } if focus() != "Chat message" { t.Fatal("chat input not reached:", focus()) } marker := fmt.Sprintf("Fenrir-private-test-%x", time.Now().UnixNano()&0xffff) start = len(output) write("/msg " + username + " " + marker + "\r") waitFor("server-delivered private self-message", 5*time.Second, func() bool { result, _ := adapter.Command(command.ReadLiveRegion1) return strings.Contains(result.Text, marker) && strings.HasPrefix(result.Text, "*") }) if !strings.Contains(strings.Join(output[start:], "\n"), marker) { t.Fatal("chat updated recall without automatic output") } for _, id := range []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3} { result, handled := adapter.Command(id) if !handled || result.Text == "" { t.Fatal("empty semantic region", id) } } saveScreen("chat-screen") var shell prompt.ShellDetector shell.Observe(emulator.snapshot(true, false)) send("\x1bOB") waitFor("quit control", 2*time.Second, func() bool { return focus() == "Quit Ninjam" }) send("\r") waitFor("real shell return", 5*time.Second, func() bool { return strings.Contains(screenText(), "[Username@host test] $") }) if !shell.Observe(emulator.snapshot(true, false)) { t.Fatal("fresh shell prompt after real cninjam exit was not recognized") } t.Log("PASS: live master/local mute, volume/pan, navigation after review, quiet meters, rename/input prompts, resize, private incoming chat, semantic region reads, and shell return") } // start_ninjam_live_peer adds a clocked-silence test channel, never a microphone. // It exists only in the explicit two-account test and is cleaned up by t.Cleanup. func start_ninjam_live_peer(t *testing.T, program, address, preload, root string) (func(), func() string, func(string)) { t.Helper() directory, err := os.MkdirTemp(root, "peer-") if err != nil { t.Fatal(err) } configuration := "master mastervol 0 masterpan 0 metrovol 0 metropan 0 mastermute 1 metromute 1\nlocal 0 source 0 bc 1 mute 1 volume 0 pan 0 name \"Peer Fixture\"\n" if err := os.WriteFile(filepath.Join(directory, "ninjam.config"), []byte(configuration), 0600); err != nil { t.Fatal(err) } master, slave, err := open_pty(24, 100) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = syscall.Close(master) }) slaveFile := os.NewFile(uintptr(slave), "private cninjam peer PTY") t.Cleanup(func() { _ = slaveFile.Close() }) name := fmt.Sprintf("UsernamePeer%04x", time.Now().UnixNano()&0xffff) child := exec.Command(program, address, "-user", "anonymous:"+name, "-alsaconfig", "in null out null srate 48000 nch 2 bps 16 bsize 2048 nblock 4", "-nosavesourcefiles", "-nosavelocal", "-nowritelog", "-sessiondir", filepath.Join(directory, "session")) child.Dir = directory child.Env = append(os.Environ(), "HOME="+directory, "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "ALSA_CONFIG_PATH=/usr/share/alsa/alsa.conf", "LD_PRELOAD="+preload) child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} if err := child.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = syscall.Kill(-child.Process.Pid, syscall.SIGTERM); _ = child.Wait() }) if err := syscall.SetNonblock(master, true); err != nil { t.Fatal(err) } emulator, err := new_emulator(24, 100) if err != nil { t.Fatal(err) } t.Cleanup(emulator.close) adapter := &ninjam.Adapter{} raw, err := os.OpenFile(filepath.Join(directory, "terminal.ansi"), os.O_CREATE|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = raw.Close() }) write := func(data string) { t.Helper() if _, err := syscall.Write(master, []byte(data)); err != nil { t.Fatal(err) } } accepted := false lastPage := time.Time{} pump := func() { var buffer [32768]byte for total := 0; total < 131072; { count, err := syscall.Read(master, buffer[:]) if count > 0 { total += count emulator.feed(buffer[:count]) if _, err := raw.Write(buffer[:count]); err != nil { t.Fatal(err) } } if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EIO) || count == 0 { break } if err != nil && !errors.Is(err, syscall.EINTR) { t.Fatal(err) } } snapshot := emulator.snapshot(true, false) text := string(snapshot.Cells) if !accepted && strings.Contains(text, "Hit Y to agree") { write("y") accepted = true } if !accepted && strings.Contains(text, "Use the arrows or pagedown to scroll down") && time.Since(lastPage) > 100*time.Millisecond { write("\x1b[6~") lastPage = time.Now() } adapter.Observe(snapshot) } focus := func() string { result, _ := adapter.Command(command.ReadLiveRegion2); return result.Text } return pump, focus, write }