Fixed terminal bug. Fixed tests which could leave the system without proper accessibility setup which could cause cthulhu to fail to start.
This commit is contained in:
1 parent
9e0ff41b16
commit
d717b93849
30 files changed
+611
-22
No files matched your search
@@ -278,6 +278,10 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
return
|
||||
}
|
||||
if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" {
|
||||
if should_ignore_terminal_scrollback(event) {
|
||||
logger.Debug("skipped terminal scrollback deletion", "role", event.Source.Role)
|
||||
return
|
||||
}
|
||||
state.pending.terminalDeletion.schedule(event, input.NoIntent, terminalMutationDebounce)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
|
||||
func should_ignore_terminal_scrollback(event atspi.Event) bool {
|
||||
// VTE can remove old complete lines at the start of its accessible text
|
||||
// while appending output at a different offset. These are not edits to
|
||||
// the prompt. This event pattern occurs on both X11 and Wayland.
|
||||
return event.Type == atspi.TextDeletedEvent &&
|
||||
event.Source.Role == "terminal" && event.Detail1 == 0 &&
|
||||
event.Source.TextCaretKnown && event.Source.BrailleLineStart > 0 &&
|
||||
event.TextChangeEndsLine && event.TextChangeLength > 0 &&
|
||||
event.TextChangeLength <= event.Source.BrailleLineStart
|
||||
}
|
||||
@@ -20,12 +20,120 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
func TestProcessEventsTerminalScrollbackDeletionBothSessions(t *testing.T) {
|
||||
for _, sessionType := range []string{"wayland", "x11"} {
|
||||
t.Run(sessionType, func(t *testing.T) {
|
||||
t.Setenv("XDG_SESSION_TYPE", sessionType)
|
||||
// Both sessions must apply the same event policy, including
|
||||
// an X11 launcher which also supplies WAYLAND_DISPLAY.
|
||||
t.Setenv("WAYLAND_DISPLAY", "wayland-test")
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(completed)
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("event loop did not stop")
|
||||
}
|
||||
})
|
||||
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Username@Host> ")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 300
|
||||
terminal.BrailleCaret = 315
|
||||
oldPrompt := "Username@Host> old command\n"
|
||||
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, oldPrompt)
|
||||
output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Current command output")
|
||||
output.Detail1 = 200
|
||||
// A caret event can arrive between the scroll and its insertion.
|
||||
caret := atspi.NewEvent(atspi.CaretEvent, terminal)
|
||||
adapter.events <- deletion
|
||||
adapter.events <- caret
|
||||
adapter.events <- output
|
||||
|
||||
for _, want := range []string{"Current command output"} {
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != want {
|
||||
t.Fatalf("speech = %q, want %q", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %q", want)
|
||||
}
|
||||
}
|
||||
for _, deleted := range []string{"x", "command"} {
|
||||
edit := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, deleted)
|
||||
edit.Detail1 = terminal.BrailleLineStart
|
||||
adapter.events <- edit
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != deleted {
|
||||
t.Fatalf("editing speech = %q, want %q", got, deleted)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for deletion %q", deleted)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalScrollbackDeletionPreservesEditing(t *testing.T) {
|
||||
t.Setenv("XDG_SESSION_TYPE", "wayland")
|
||||
terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt")
|
||||
terminal.TextCaretKnown = true
|
||||
terminal.BrailleLineStart = 100
|
||||
terminal.BrailleCaret = 110
|
||||
base := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt\n")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
modify func(*atspi.Event)
|
||||
ignore bool
|
||||
}{
|
||||
{"scroll", func(event *atspi.Event) {}, true},
|
||||
{"unicode offsets", func(event *atspi.Event) {
|
||||
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("猫", 40)+"\n")
|
||||
}, true},
|
||||
{"backspace", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 109 }, false},
|
||||
{"delete", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 110 }, false},
|
||||
{"delete word", func(event *atspi.Event) { event.Payload = "command"; event.Detail1 = 100 }, false},
|
||||
{"first character", func(event *atspi.Event) {
|
||||
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x")
|
||||
}, false},
|
||||
{"newline", func(event *atspi.Event) { event.Payload = "\n"; event.Detail1 = 100 }, false},
|
||||
{"current first line", func(event *atspi.Event) { event.Source.BrailleLineStart = 0 }, false},
|
||||
{"range reaches current line", func(event *atspi.Event) { event.Source.BrailleLineStart = 5 }, false},
|
||||
{"unknown caret", func(event *atspi.Event) { event.Source.TextCaretKnown = false }, false},
|
||||
{"partial line", func(event *atspi.Event) {
|
||||
*event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt")
|
||||
}, false},
|
||||
{"editor", func(event *atspi.Event) { event.Source.Role = "text" }, false},
|
||||
{"insertion", func(event *atspi.Event) { event.Type = atspi.TextInsertedEvent }, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
event := base
|
||||
test.modify(&event)
|
||||
if got := should_ignore_terminal_scrollback(event); got != test.ignore {
|
||||
t.Fatalf("ignore = %v, want %v", got, test.ignore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
|
||||
@@ -295,6 +295,10 @@ type Event struct {
|
||||
OriginPath string
|
||||
Payload string
|
||||
Detail1 int
|
||||
// TextChangeLength and TextChangeEndsLine describe the original payload,
|
||||
// before speech normalization collapses whitespace and limits its length.
|
||||
TextChangeLength int
|
||||
TextChangeEndsLine bool
|
||||
}
|
||||
|
||||
type ForegroundCheck struct {
|
||||
@@ -375,6 +379,10 @@ func NewManagedDescendantEvent(eventType, originPath string, source Snapshot) Ev
|
||||
func NewEventWithPayload(eventType string, source Snapshot, payload string) Event {
|
||||
event := NewEvent(eventType, source)
|
||||
event.Payload = sanitizeTextPayload(payload)
|
||||
if event.Type == TextInsertedEvent || event.Type == TextDeletedEvent {
|
||||
event.TextChangeLength = utf8.RuneCountInString(payload)
|
||||
event.TextChangeEndsLine = strings.HasSuffix(payload, "\n")
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,16 @@ func TestNewEventWithPayloadSanitizesPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextChangeRetainsOriginalRangeMetadata(t *testing.T) {
|
||||
source := NewSnapshot("/accessible/terminal", "", "terminal", "", "")
|
||||
for _, eventType := range []string{TextInsertedEvent, TextDeletedEvent, TextDeletedEvent + ":system"} {
|
||||
event := NewEventWithPayload(eventType, source, "猫 command\r\n")
|
||||
if event.Payload != "猫 command" || event.TextChangeLength != 12 || !event.TextChangeEndsLine {
|
||||
t.Fatalf("text change = %#v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventWithPayloadPreservesInsertedSpace(t *testing.T) {
|
||||
event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/editor", "", "text", "", ""), " ")
|
||||
if event.Payload != " " {
|
||||
|
||||
Reference in new issue
Block a user