// 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 . //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) } } }