Initial support for application specific overrides.
This commit is contained in:
1 parent
901fd58ba8
commit
b45f800489
37 files changed
+3909
-475
No files matched your search
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build linux && cgo
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam"
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/reader"
|
||||
)
|
||||
|
||||
// Exercise actual libvterm attribute decoding, including ncurses' monochrome
|
||||
// standout path. These are source-derived terminal writes, not a running client.
|
||||
func TestNinjamOverrideTerminalPresentation(t *testing.T) {
|
||||
for _, monochrome := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(monochrome), func(t *testing.T) {
|
||||
e, err := new_emulator(24, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.close()
|
||||
r, a := reader.New(), &ninjam.Adapter{}
|
||||
paint := func(selected int, volume, chat string) []string {
|
||||
e.feed([]byte("\x1b[0m\x1b[2J\x1b[HLOCAL\x1b[2;1H"))
|
||||
field := 0
|
||||
for _, char := range " master: [ ]mute [" + volume + "] | metronome: [ ]mute [0.0dB center]" {
|
||||
if char == ']' {
|
||||
e.feed([]byte("\x1b[0m"))
|
||||
}
|
||||
e.feed([]byte(string(char)))
|
||||
if char == '[' {
|
||||
style := "\x1b[1m"
|
||||
if field == selected {
|
||||
style = "\x1b[0;30;47m"
|
||||
if monochrome {
|
||||
style = "\x1b[0;7m"
|
||||
}
|
||||
}
|
||||
e.feed([]byte(style))
|
||||
field++
|
||||
}
|
||||
}
|
||||
e.feed([]byte("\x1b[17;1HCHAT\x1b[20;1H<Username> old\x1b[21;1H" + chat + "\x1b[23;1H#####\x1b[24;1H[QUIT NINJAM] : fixture.invalid : 120.0BPM 16BPI\x1b[24;1H"))
|
||||
s := e.snapshot(true, false)
|
||||
var result []string
|
||||
for _, message := range r.Update_application(s, a.Observe(s)) {
|
||||
if message.Text != "" {
|
||||
result = append(result, message.Text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
if got := paint(0, "0.0dB center", ""); !slices.Equal(got, []string{"Master unmuted"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
r.Command(command.CurrentLine)
|
||||
r.Note_input(true, false, false)
|
||||
if got := paint(1, "0.0dB center", "<Username> new"); !slices.Equal(got, []string{"Master Volume and pan 0.0dB center", "<Username> new"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := paint(1, "-0.5dB center", "<Username> new"); !slices.Equal(got, []string{"Master Volume and pan -0.5dB center"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build linux && cgo
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/applicationoverrides/networkmanager"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
)
|
||||
|
||||
// Opt-in read-only application check: open nmtui's main menu, move its
|
||||
// selection, and exit. No connection, hostname or network setting is changed.
|
||||
func TestNetworkManagerOverrideNative(t *testing.T) {
|
||||
if os.Getenv("FENRIR_APPLICATION_OVERRIDE_NATIVE") != "1" {
|
||||
t.Skip("set FENRIR_APPLICATION_OVERRIDE_NATIVE=1 for private nmtui/Screen PTY checks")
|
||||
}
|
||||
nmtui, err := exec.LookPath("nmtui")
|
||||
if err != nil {
|
||||
t.Skip("nmtui unavailable")
|
||||
}
|
||||
for _, multiplexed := range []bool{false, true} {
|
||||
name := "direct"
|
||||
if multiplexed {
|
||||
name = "screen"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
env := append(os.Environ(), "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "NEWT_COLORS=")
|
||||
cmd := exec.Command(nmtui)
|
||||
if multiplexed {
|
||||
path, err := exec.LookPath("screen")
|
||||
if err != nil {
|
||||
t.Skip("GNU Screen unavailable")
|
||||
}
|
||||
socketDirectory := t.TempDir()
|
||||
if err := os.Chmod(socketDirectory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = append(env, "SCREENDIR="+socketDirectory)
|
||||
cmd = exec.Command(path, "-c", "/dev/null", "-m", "-S", "fenrir-override-test", nmtui)
|
||||
defer func() {
|
||||
quit := exec.Command(path, "-S", "fenrir-override-test", "-X", "quit")
|
||||
quit.Env = env
|
||||
_ = quit.Run()
|
||||
}()
|
||||
}
|
||||
master, slave, err := open_pty(24, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer syscall.Close(master)
|
||||
slaveFile := os.NewFile(uintptr(slave), "private nmtui PTY")
|
||||
defer slaveFile.Close()
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = slaveFile, slaveFile, slaveFile
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _ = cmd.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()
|
||||
adapter := &networkmanager.Adapter{}
|
||||
var before []screen.Attribute
|
||||
waitForMenu := func() {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
var buffer [32768]byte
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := syscall.Read(master, buffer[:])
|
||||
if n > 0 {
|
||||
emulator.feed(buffer[:n])
|
||||
}
|
||||
if err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EINTR) {
|
||||
t.Fatalf("nmtui PTY: %v; screen: %s", err, string(emulator.snapshot(true, false).Cells))
|
||||
}
|
||||
snapshot := emulator.snapshot(true, false)
|
||||
if adapter.Observe(snapshot).Recognized && (before == nil || !slices.Equal(before, snapshot.Attributes)) {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("nmtui menu not recognized: %s", strings.TrimSpace(string(emulator.snapshot(true, false).Cells)))
|
||||
}
|
||||
waitForMenu()
|
||||
before = slices.Clone(emulator.snapshot(true, false).Attributes)
|
||||
// GNU Screen and xterm both forward the normal arrow sequence.
|
||||
if _, err := syscall.Write(master, []byte("\x1bOB")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForMenu()
|
||||
if _, err := syscall.Write(master, []byte("\x1b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// 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"
|
||||
"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") && strings.Contains(strings.Join(output[start:], "\n"), focus())
|
||||
})
|
||||
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") && strings.Contains(strings.Join(output[start:], "\n"), focus())
|
||||
})
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user