// 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 . package app import ( "context" "os" "path/filepath" "reflect" "slices" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/screen" "git.stormux.org/storm/fenrir/internal/scripts" ) func TestHungScriptDoesNotBlockReviewOrReaderShutdown(t *testing.T) { root := t.TempDir() path, marker := filepath.Join(root, "script.sh"), filepath.Join(root, "started") if err := os.WriteFile(path, []byte("#!/bin/sh\necho started > '"+marker+"'\nsleep 30\n"), 0700); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil) defer runner.Close() script := scripts.Script{ID: "script:hang", Path: path} source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}} keys := &fakeKeys{events: make(chan error, 1)} voice := &fakeVoice{} polls := 0 keys.poll = func(_ bool, handle func(input.Decision)) error { polls++ if polls == 1 { handle(input.Decision{Command: script.ID, Interrupt: true}) } else { handle(input.Decision{Command: command.CurrentLine, Interrupt: true}) handle(input.Decision{Command: command.Quit}) } return nil } go func() { ticker := time.NewTicker(5 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if _, err := os.Stat(marker); err == nil { keys.events <- nil return } } } }() started := time.Now() err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner}) if err != nil || ctx.Err() != nil || time.Since(started) > time.Second || !keys.closed || !slices.Contains(voice.messages, "alpha") { t.Fatal("script stalled reader", err, ctx.Err(), voice.messages) } } func TestScriptResultsUseMainLoopStderrThenStdout(t *testing.T) { path := filepath.Join(t.TempDir(), "script.sh") if err := os.WriteFile(path, []byte("#!/bin/sh\necho failure >&2\necho result\nexit 7\n"), 0700); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil) defer runner.Close() script := scripts.Script{ID: "script:result", Path: path} source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}} keys := &fakeKeys{events: make(chan error, 1)} voice := &fakeVoice{onSay: func(text string) { if text == "result\n" { keys.events <- nil } }} polls := 0 keys.poll = func(_ bool, handle func(input.Decision)) error { polls++ if polls == 1 { handle(input.Decision{Command: script.ID}) } else { handle(input.Decision{Command: command.Quit}) } return nil } err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner}) if err != nil || ctx.Err() != nil { t.Fatal(err, ctx.Err()) } if len(voice.messages) < 2 || !reflect.DeepEqual(voice.messages[len(voice.messages)-2:], []string{"failure\n", "result\n"}) { t.Fatal(voice.messages) } } func TestRemoteSettingsAreAtomicAndUsePythonNames(t *testing.T) { base := config.Defaults() updated, err := remote_settings(base, "set speech#enabled=False;speech#auto_read_incoming=False,keyboard#word_echo=True") if err != nil || updated.Presentation.Speech.Enabled || updated.Reading.Automatic || !updated.Keyboard.WordEcho { t.Fatal(updated, err) } failed, err := remote_settings(base, "SET speech#enabled=False;unknown#setting=True") if err == nil || !reflect.DeepEqual(failed, base) { t.Fatal("partial settings applied", failed, err) } }