Files
fenrir/internal/terminal/emulator_linux_test.go

351 lines
12 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
//go:build linux && cgo
package terminal
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/reader"
"git.stormux.org/storm/fenrir/internal/screen"
)
func TestPTYAttributeOnlyHighlightMovement(t *testing.T) {
emulator, err := new_emulator(4, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
emulator.feed([]byte("\x1b[7mFirst message\x1b[0m\r\nSecond message\r\nThird message\x1b[4;1H"))
before := emulator.snapshot(true, false)
if len(before.Attributes) != len(before.Cells) || before.Attributes[0] == before.Attributes[30] {
t.Fatal("reverse video attributes were discarded")
}
engine := reader.New()
engine.Configure_tracking("highlight")
engine.Update(before)
emulator.feed([]byte("\x1b[1;1HFirst message\x1b[2;1H\x1b[7mSecond message\x1b[0m\x1b[4;1H"))
after := emulator.snapshot(true, false)
if !reflect.DeepEqual(before.Cells, after.Cells) || before.X != after.X || before.Y != after.Y {
t.Fatal("fixture changed text or hardware cursor")
}
if got := engine.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Second message"}}) {
t.Fatal("PTY selection was silent or read deselected text", got)
}
if before.Attributes[0] == before.Attributes[30] {
t.Fatal("snapshot attribute storage was reused")
}
// Colours as well as style changes must survive conversion, including RGB.
emulator.feed([]byte("\x1b[1;1H\x1b[1;38;2;12;34;56;48;2;65;43;21mColor\x1b[0m"))
colored := emulator.snapshot(true, false)
want := screen.Attribute{Foreground: 0x0c2238, Background: 0x412b15, Style: 1}
if colored.Attributes[0] != want {
t.Fatal("RGB/style attributes", colored.Attributes[0], want)
}
}
func TestPTYUnicodeSplitSequencesAndSharedReview(t *testing.T) {
emulator, err := new_emulator(4, 20)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
bytes := []byte("alpha\r\né界\r\nthird\x1b[2;2H")
// Split every UTF-8 character and escape sequence across reads.
for _, value := range bytes {
emulator.feed([]byte{value})
}
snapshot := emulator.snapshot(true, false)
if snapshot.Text(0) != "alpha" || snapshot.Text(1) != "é界" || snapshot.X != 1 || snapshot.Y != 1 {
t.Fatalf("wrong PTY screen: %+v, %q", snapshot, snapshot.Cells)
}
engine := reader.New()
engine.Update(snapshot)
for _, fixture := range []struct {
action command.ID
want string
}{
{command.CurrentLine, "é界"}, {command.PreviousLine, "alpha"},
{command.CurrentWord, "alpha"}, {command.CurrentChar, "a"},
{command.ReadScreen, "alpha\né界\nthird"},
} {
if got := engine.Command(fixture.action).Text; got != fixture.want {
t.Fatalf("shared %s: got %q, want %q", fixture.action, got, fixture.want)
}
}
}
func TestPTYAlternateScreenResizeAndScroll(t *testing.T) {
emulator, err := new_emulator(3, 12)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
emulator.feed([]byte("shell\x1b[?1049h\x1b[2J\x1b[Heditor"))
alternate := emulator.snapshot(true, false)
engine.Update(alternate)
engine.Command(command.NextLine)
if got := emulator.snapshot(true, false).Text(0); got != "editor" {
t.Fatal("alternate screen", got)
}
emulator.feed([]byte("\x1b[?1049l"))
main := emulator.snapshot(true, false)
if got := main.Text(0); got != "shell" {
t.Fatal("main screen lost", got)
}
if main.Generation == alternate.Generation {
t.Fatal("alternate buffer reused its screen generation")
}
engine.Update(main)
if got := engine.Command(command.CurrentLine).Text; got != "shell" {
t.Fatal("review was not reset for the restored buffer", got)
}
emulator.resize(4, 20)
snapshot := emulator.snapshot(true, false)
if snapshot.Rows != 4 || snapshot.Cols != 20 || len(snapshot.Cells) != 80 || snapshot.Text(0) != "shell" {
t.Fatal("resize", snapshot)
}
emulator.feed([]byte("\x1b[2J\x1b[Hone\r\ntwo\r\nthree\r\nfour\r\nfive"))
snapshot = emulator.snapshot(true, false)
var lines []string
for row := range snapshot.Rows {
lines = append(lines, snapshot.Text(row))
}
if !reflect.DeepEqual(lines, []string{"two", "three", "four", "five"}) {
t.Fatal("scroll", lines)
}
}
func TestPTYReverseSearchPromptReplacement(t *testing.T) {
for _, promptWidth := range []int{2, 23, 24, 25, 26, 40} {
t.Run(fmt.Sprint(promptWidth), func(t *testing.T) {
emulator, err := new_emulator(3, 80)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
emulator.feed([]byte(strings.Repeat("p", promptWidth-2) + "$ "))
engine.Update(emulator.snapshot(true, false))
// Bash Readline repaints the prompt and clears any remaining tail.
searchPrompt := "(reverse-i-search)`': "
emulator.feed([]byte("\r" + searchPrompt + "\x1b[K"))
if got := engine.Update(emulator.snapshot(true, false)); len(got) != 1 || got[0].Character != nil || strings.TrimRight(got[0].Text, " ") != strings.TrimRight(searchPrompt, " ") {
t.Fatalf("search prompt: got %+v, want %q", got, searchPrompt)
}
})
}
}
func TestPTYCarriageReturnEraseAndCursorEcho(t *testing.T) {
emulator, err := new_emulator(3, 20)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Update(emulator.snapshot(true, false))
emulator.feed([]byte("hello"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "hello"}}) {
t.Fatal("shared automatic reading", got)
}
emulator.feed([]byte("\b \b"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('o')}) {
t.Fatal("shared deletion echo", got)
}
emulator.feed([]byte("\rnew\x1b[K"))
if got := emulator.snapshot(true, false).Text(0); got != "new" {
t.Fatal("repaint/erase", got)
}
emulator.feed([]byte("\x1b[2;1Hnext\x1b[1;1H"))
engine.Update(emulator.snapshot(true, false))
emulator.feed([]byte("\x1b[2;1H"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "next"}}) {
t.Fatal("shared cursor echo", got)
}
}
func TestPTYChatScrollRegionPreservesFixedRows(t *testing.T) {
emulator, err := new_emulator(7, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
emulator.feed([]byte("header\r\nold\r\nkeep one\r\nkeep two\r\nlast\r\nstatus\r\n> draft\x1b[2;5r\x1b[7;8H"))
engine.Update(emulator.snapshot(true, false))
// DECSTBM + line feed shifts only the chat area, as in irssi. The
// application restores the cursor to its unchanged input line afterward.
emulator.feed([]byte("\x1b[5;1H\n\rnew message\x1b[7;8H"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "new message"}}) {
t.Fatal(got)
}
if got := emulator.snapshot(true, false); got.Text(0) != "header" || got.Text(5) != "status" || got.Text(6) != "> draft" {
t.Fatal(got)
}
}
func TestTerminalEnvironmentValidation(t *testing.T) {
t.Setenv("WINDOWID", "invalid")
if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "WINDOWID") {
t.Fatal("invalid target", err)
}
t.Setenv("WINDOWID", "123")
t.Setenv("SHELL", "")
if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "SHELL") {
t.Fatal("missing shell", err)
}
for _, size := range [][2]int{{0, 80}, {25, 0}, {1001, 80}, {1000, 2000}} {
if emulator, err := new_emulator(size[0], size[1]); err == nil {
emulator.close()
t.Fatal("accepted excessive geometry", size)
}
}
}
func TestPTYTypingEchoAtMarginsAndWithWideCharacters(t *testing.T) {
for _, enabled := range []bool{false, true} {
emulator, err := new_emulator(3, 5)
if err != nil {
t.Fatal(err)
}
engine := reader.New()
engine.Configure_echo(enabled, false, false)
engine.Update(emulator.snapshot(true, false))
for _, char := range "abcdefghijklmnop q 界r" {
engine.Note_input(true, true, char == ' ')
emulator.feed([]byte(string(char)))
var want []presentation.Message
if enabled && char != ' ' {
want = []presentation.Message{presentation.Character_message(char)}
}
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, want) {
t.Fatalf("echo=%v, char=%q: got %v, want %v", enabled, char, got, want)
}
}
emulator.close()
}
}
func TestProgressAfterTerminalScroll(t *testing.T) {
for _, mode := range []string{"percent", "spinner", "scroll region"} {
t.Run(mode, func(t *testing.T) {
emulator, err := new_emulator(79, 318)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_progress(true)
// Fill the screen, then produce newline-terminated updates exactly as a
// script does at the bottom of the viewport. A fixed status row exercises
// a terminal multiplexer-style scrolling region too.
if mode == "scroll region" {
emulator.feed([]byte("\x1b[1;78r\x1b[79;1Hstatus\x1b[78;1H"))
}
for index := 0; index < 90; index++ {
emulator.feed([]byte(fmt.Sprintf("previous output %03d\r\n", index)))
}
engine.Update(emulator.snapshot(true, false))
now := time.Now()
for index := 1; index <= 3; index++ {
text := fmt.Sprintf("Progress %d%%\r\n", index*10)
want := float64(index * 10)
if mode == "spinner" {
text = string([]rune("⠋⠙⠹")[index-1]) + " Preparing files\r\n"
want = 50
}
emulator.feed([]byte(text))
engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(now.Add(time.Duration(index) * 2 * time.Second)); !ok || value != want {
t.Fatalf("scroll update %d: value=%v accepted=%v", index, value, ok)
}
}
})
}
}
func TestProgressIgnoresTerminalBackspaceAndDelete(t *testing.T) {
for _, key := range []string{"backspace", "delete"} {
t.Run(key, func(t *testing.T) {
emulator, err := new_emulator(3, 40)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_progress(true)
emulator.feed([]byte("type 2% abc"))
if key == "delete" {
emulator.feed([]byte("\x1b[1;11H"))
}
engine.Update(emulator.snapshot(true, false))
engine.Note_input(true, false, false)
engine.Note_deletion()
if key == "backspace" {
emulator.feed([]byte("\b \b"))
} else {
emulator.feed([]byte("\x1b[P"))
}
output := engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(time.Now()); ok {
t.Fatal("deletion beeped", value)
}
if key == "backspace" && !reflect.DeepEqual(output, []presentation.Message{presentation.Character_message('c')}) {
t.Fatal("deletion feedback lost", output)
}
emulator.feed([]byte("\x1b[2;1HDownload 30%\x1b[1;11H"))
engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(time.Now()); !ok || value != 30 {
t.Fatal("background progress stopped", value, ok)
}
})
}
}
func TestPTYAttributesReportStylesPaletteRGBAndDefaults(t *testing.T) {
emulator, err := new_emulator(2, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_attributes("speech", "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirUnderline|fenrirItalics|fenrirBlink|fenrirReverse|fenrirStrikethrough")
for _, trial := range []struct{ sgr, want string }{
{"1;4;3;5;7;9;31;44", "red|blue|bold|underline|italic|blink|reverse|strikethrough"},
{"38;5;196;48;5;232", "#ff0000|#080808||||||"},
{"38;2;12;34;56;48;2;65;43;21", "#0c2238|#412b15||||||"},
{"0", "default|default||||||"},
} {
emulator.feed([]byte("\x1b[H\x1b[0m\x1b[" + trial.sgr + "mX\x1b[0m\x1b[H"))
engine.Update(emulator.snapshot(true, false))
if got := engine.Command(command.AttributeCursor).Text; got != trial.want {
t.Fatal(trial, got)
}
}
}