Files
cthulhu/internal/app/browser_events_test.go

1050 lines
42 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
type autocompleteRequestQueue struct{ requests chan speechRequest }
func TestBrowserNewTabsKeepAddressFocusAndSuppressURLRewrites(t *testing.T) {
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 32)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
}()
defer func() { cancel(); <-done }()
expect := func(want string) speechRequest {
t.Helper()
select {
case request := <-queue.requests:
if request.Text != want {
t.Fatalf("speech = %q, want %q", request.Text, want)
}
return request
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
return speechRequest{}
}
}
page := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/page")
page.Source.Role, page.Source.Name = "document web", "Original page"
adapter.events <- page
expect("Original page, document web")
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
tracker.Observe(input.NewKeyEvent(0, true, "t"))
tab := ownerEvent(atspi.SelectedStateEvent, ":1.7", "/window", "/tab1")
tab.Source.Role, tab.Source.Name, tab.Source.BrowserTab = "page tab", "New Tab", true
tab.Detail1 = 1
adapter.events <- tab
expect("New Tab, page tab")
entry := ownerEvent(atspi.FocusEvent, ":1.7", "/window", "/address")
entry.Source.Role, entry.Source.Name = "entry", "Address"
entry.Source.Editable, entry.Source.Autocomplete, entry.Source.Focused = true, true, true
adapter.events <- entry
if request := expect("Address, autocomplete"); !request.Append {
t.Fatal("address focus would interrupt new-tab announcement")
}
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "previous.example")
adapter.events <- deletion
tracker.Observe(input.NewKeyEvent(0, true, "t"))
tab.Source.Path = "/tab2"
adapter.events <- tab
expect("New Tab, page tab") // No address focus event for the second Ctrl+T.
adapter.events <- tab // Duplicate selected-state event.
selection := tab
selection.Type, selection.OriginPath = atspi.SelectionChangedEvent, "/tabs"
adapter.events <- selection
selection.Type, selection.Detail1 = atspi.SelectedStateEvent, 0
adapter.events <- selection
background := tab
background.Window.Path, background.Source.Path = "/background", "/backgroundTab"
adapter.events <- background
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
tracker.Observe(input.NewKeyEvent(0, true, "a"))
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry.Source, "a")
expect("a") // The selected tab did not steal the owned address focus.
tracker.Observe(input.NewKeyEvent(0, true, "BackSpace"))
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "a")
expect("a")
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
tracker.Observe(input.NewKeyEvent(0, true, "x"))
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "selected.example")
expect("selected.example")
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
tab.Source.Path, tab.Source.Name = "/tab1", "First tab"
adapter.events <- tab
expect("First tab, page tab")
// Firefox may report only a tab-list selection, followed by a delayed
// selected-state gain from the previous tab. Fresh state rejects that gain.
selection = tab
selection.Type, selection.Source.Path, selection.Source.Name = atspi.SelectionChangedEvent, "/tab3", "Third tab"
selection.Source.StatesKnown, selection.Source.Selected = true, true
selection.Source.ToolkitName = "Gecko"
adapter.events <- selection
expect("Third tab, page tab")
adapter.events <- selection
tab.Source.StatesKnown, tab.Source.Selected = true, false
adapter.events <- tab
marker := entry
marker.Source.Path, marker.Source.Name = "/marker", "End"
marker.Source.Role, marker.Source.Autocomplete = "button", false
adapter.events <- marker
expect("End, button") // Marker also catches unexpected queued announcements.
}
func (queue *autocompleteRequestQueue) Enqueue(request speechRequest) { queue.requests <- request }
func (queue *autocompleteRequestQueue) Interrupt() {}
func TestAutocompleteAnnouncesSelectedAddressWhenItChanges(t *testing.T) {
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
options := presentation.DefaultOptions()
options.EnableSound = true
options.PlaySoundForRole = true
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options)
}()
defer func() { cancel(); <-done }()
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true, Editable: true, Focused: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
select {
case request := <-queue.requests:
if request.Text != "autocomplete" || len(request.Sound) != 0 {
t.Fatalf("focus request = %#v", request)
}
case <-time.After(time.Second):
t.Fatal("missing address-bar focus")
}
tracker.Observe(input.NewKeyEvent(0, true, "s"))
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s")
select {
case request := <-queue.requests:
if len(request.Sound) != 0 {
t.Fatalf("ordinary typing sounded like a completion: %#v", request)
}
case <-time.After(time.Second):
t.Fatal("missing typing presentation")
}
entry.Text = "https://stormux.org"
entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text))
inserted := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggestion")
inserted.Detail1 = 1
adapter.events <- inserted
select {
case request := <-queue.requests:
if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" {
t.Fatalf("completion request = %#v", request)
}
case <-time.After(time.Second):
t.Fatal("missing autocomplete sound")
}
popup := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Other result", AutocompletePopup: true})
popup.PopupFor = entry.ID()
adapter.events <- popup
adapter.events <- inserted
select {
case request := <-queue.requests:
if request.Text == entry.Text || len(request.Sound) != 0 {
t.Fatalf("duplicate selected address announcement = %#v", request)
}
case <-time.After(40 * time.Millisecond):
}
tracker.Observe(input.NewKeyEvent(0, true, "t"))
entry.Text = "https://stormux.org/docs"
entry.SelectionStart, entry.SelectionEnd = 2, len([]rune(entry.Text))
updated := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "updated suggestion")
updated.Detail1 = 2
adapter.events <- updated
select {
case request := <-queue.requests:
if request.Text != entry.Text || len(request.Sound) != 1 || request.Sound[0].Name != "autocomplete" {
t.Fatalf("updated address request = %#v", request)
}
case <-time.After(time.Second):
t.Fatal("missing updated address announcement")
}
}
func TestAutocompleteAnnouncementRequiresSelectedAddress(t *testing.T) {
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Autocomplete: true}
var availability autocompleteAvailability
availability.focus(atspi.NewEvent(atspi.FocusEvent, entry))
edit := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "h")
activity := input.Activity{Intent: input.TypingIntent, Sequence: 1}
if got := availability.offered(edit, activity); got != "" {
t.Fatalf("unselected text sounded as completion: %q", got)
}
entry.Text = "https://stormux.org"
entry.SelectionStart, entry.SelectionEnd = 1, len([]rune(entry.Text))
edit.Source = entry
if got := availability.offered(edit, input.Activity{}); got != "" {
t.Fatalf("completion without user edit sounded: %q", got)
}
if got := availability.offered(edit, activity); got != entry.Text {
t.Fatalf("selected address = %q, want %q", got, entry.Text)
}
if got := availability.offered(edit, input.Activity{Intent: input.TypingIntent, Sequence: 2}); got != "" {
t.Fatalf("unchanged address sounded again: %q", got)
}
other := edit
other.Source.Path = "/other"
other.Source.Text = "https://different.example"
if got := availability.offered(other, activity); got != "" {
t.Fatalf("other entry sounded as owned address: %q", got)
}
}
func TestFirstBrowserLoadKeepsActivationSpeech(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document")
activation.Source.Role, activation.Source.Name = "document web", "First page"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "First page, document web")
load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/document")
load.Source.Role, load.Source.Name = "document web", "First page"
adapter.events <- load
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
button := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/button")
button.Source.Role, button.Source.Name = "button", "Next"
button.Detail1 = 1
adapter.events <- button
expectActivationSpeech(t, queue.spoken, "Next, button")
select {
case <-queue.interrupt:
t.Fatal("first load interrupted its own activation speech")
default:
}
load.Source.Path = "/second-document"
adapter.events <- load
adapter.events <- button
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
expectActivationSpeech(t, queue.spoken, "Next, button")
select {
case <-queue.interrupt:
t.Fatal("document completion interrupted earlier speech")
default:
}
}
func TestBrowserTypingAndNavigation(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 32)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
}()
defer func() { cancel(); <-done }()
expect := func(want string) {
t.Helper()
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
quiet := func() {
t.Helper()
select {
case got := <-queue.spoken:
t.Fatalf("unexpected %q", got)
case <-time.After(30 * time.Millisecond):
}
}
key := func(name string) { tracker.Observe(input.NewKeyEvent(0, true, name)) }
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, Focused: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
expect("autocomplete")
key("s")
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous.example")
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "s")
expect("s")
// Suggestions can clear the native focused bit without changing the owned entry.
entry.Focused = false
key("t")
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "t")
expect("t")
// Chromium inserts the selected completion and emits several popup events.
entry.SelectionStart, entry.SelectionEnd = 1, 11
completion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "tormux.org")
completion.Detail1 = 1
adapter.events <- completion
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
popup := atspi.Snapshot{BusName: ":1.7", Path: "/suggestion", Role: "list item", Name: "Suggested site", Selected: true, AutocompletePopup: true}
popupFocus := atspi.NewEvent(atspi.FocusedStateEvent, popup)
popupFocus.Detail1 = 1
adapter.events <- popupFocus
adapter.events <- atspi.NewEvent("object:state-changed:selected", popup)
adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup)
quiet()
// Explicit suggestion navigation still speaks the chosen item, once.
key("Down")
adapter.events <- popupFocus
expect("Suggested site, list item, selected")
adapter.events <- atspi.NewEvent("object:state-changed:selected", popup)
adapter.events <- atspi.NewEvent(atspi.SelectionChangedEvent, popup)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "suggested.example")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
quiet()
page := atspi.Snapshot{BusName: ":1.7", Path: "/paragraph", Role: "paragraph", Text: "Plain document text", CaretWord: "document", CaretCharacter: "d"}
key("Down")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
expect("Plain document text")
blank := page
blank.Text, blank.CaretCharacter = "", "\n"
adapter.events <- atspi.NewEvent(atspi.CaretEvent, blank)
expect("blank")
key("Control_L")
key("Right")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
expect("document")
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
key("Right")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, page)
expect("d")
}
func TestAutocompleteFocusCaretDoesNotInterruptAnnouncement(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
}()
defer func() { cancel(); <-done }()
entry := atspi.Snapshot{
BusName: ":1.7", Path: "/address", Name: "Address and search bar", Role: "entry",
Editable: true, Autocomplete: true, Focused: true, Text: "https://example.test",
SelectionEnd: len("https://example.test"), CaretCharacter: "h",
}
tracker.Observe(input.NewKeyEvent(0, true, "Control_L"))
tracker.Observe(input.NewKeyEvent(0, true, "l"))
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
select {
case got := <-queue.spoken:
if want := "Address and search bar, autocomplete, https://example.test, selected"; got != want {
t.Fatalf("focus speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatal("missing address-bar focus speech")
}
select {
case got := <-queue.spoken:
t.Fatalf("focus caret interrupted announcement with %q", got)
case <-time.After(40 * time.Millisecond):
}
tracker.Observe(input.NewKeyEvent(0, false, "Control_L"))
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
adapter.events <- atspi.NewEvent(atspi.CaretEvent, entry)
select {
case got := <-queue.spoken:
if got != "h" {
t.Fatalf("arrow caret speech = %q, want h", got)
}
case <-time.After(time.Second):
t.Fatal("missing arrow caret speech")
}
}
func TestFocusedStateLossIsIgnored(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
keys := &modeKeys{enabled: true}
browser := newBrowserModeController(nil, keys, nil)
browser.ready = true
browser.mode = review.BrowseMode
browser.document = atspi.NewObjectID(":1.7", "/document")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
_ = processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
lost := atspi.Snapshot{BusName: ":1.7", Path: "/button", Name: "Old", Role: "button", Focusable: true}
event := atspi.NewEvent(atspi.FocusedStateEvent, lost)
event.Detail1 = 0
adapter.events <- event
time.Sleep(30 * time.Millisecond)
if !browser.ready || browser.mode != review.BrowseMode || !keys.enabled {
t.Fatal("focus loss changed browser mode or browse grabs")
}
select {
case got := <-queue.spoken:
t.Fatalf("focus loss spoke %q", got)
default:
}
cancel()
<-done
}
func TestNewDocumentFocusIsNotRetainedOldCaret(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready, browser.mode = true, review.BrowseMode
browser.document = atspi.NewObjectID(":1.7", "/old_document")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
newDocument := atspi.Snapshot{BusName: ":1.7", Path: "/new_document", Role: "document web", Name: "New topic"}
focus := atspi.NewEvent(atspi.FocusedStateEvent, newDocument)
focus.Detail1 = 1
adapter.events <- focus
select {
case spoken := <-queue.spoken:
if spoken != "New topic, document web" {
t.Fatalf("new document focus said %q", spoken)
}
case <-time.After(time.Second):
t.Fatal("new document focus was suppressed")
}
if browser.focus != newDocument.ID() {
t.Fatalf("browser retained old focus: %v", browser.focus)
}
}
func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready, browser.mode = true, review.BrowseMode
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Name: "Address"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
select {
case <-queue.spoken:
case <-time.After(time.Second):
t.Fatal("missing entry focus")
}
oldHeading := atspi.Snapshot{BusName: ":1.42", Path: "/old-heading", Role: "heading", Text: "Old page"}
adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading)
// The event loop must finish processing the caret before it can receive
// this command. Join before inspecting controller state.
where <- input.WhereAmI
cancel()
<-done
if browser.focus != entry.ID() || browser.generation != 1 {
t.Fatalf("old document caret replaced editable focus: %v generation %d", browser.focus, browser.generation)
}
}
func TestBrowseSelectionCaretUpdatesReviewFocus(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 2)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready = true
browser.mode = review.BrowseMode
browser.document = atspi.NewObjectID(":1.42", "/document")
browser.focus = browser.document
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
selection := atspi.Snapshot{
BusName: ":1.42", Path: "/selection", Role: "paragraph",
Text: "Selected", CaretCharacter: "h",
SelectionStart: 0, SelectionEnd: 1,
}
adapter.events <- atspi.NewEvent(atspi.CaretEvent, selection)
select {
case got := <-queue.spoken:
if got != "h" {
t.Fatalf("selected caret speech = %q, want h", got)
}
case <-time.After(time.Second):
t.Fatal("selected caret did not speak")
}
if browser.focus != selection.ID() {
t.Fatalf("selection caret did not update browse focus: %v", browser.focus)
}
if browser.generation != 1 {
t.Fatalf("selection caret generation = %d, want 1", browser.generation)
}
}
func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
expect := func(want string) {
t.Helper()
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
entry := atspi.Snapshot{BusName: ":1.42", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Name: "Address"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
expect("Address, autocomplete")
document := atspi.Snapshot{BusName: ":1.42", Path: "/document", Role: "document web", Name: "Loaded page"}
load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document)
load.FocusOutsideDocument = entry.ID()
adapter.events <- load
where <- input.WhereAmI
expect("Address, autocomplete")
// A subsequent load with document focus must still announce and acquire
// the new document, even if the previous chrome snapshot was editable.
adapter.events <- atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document)
expect("Finished loading Loaded page.")
cancel()
<-done
if browser.focus != document.ID() || browser.ready || keys.enabled {
t.Fatalf("load did not schedule new document resolution: %+v", browser.focus)
}
}
func TestDocumentCompletionPreservesBackendSpeech(t *testing.T) {
for _, eventType := range []string{atspi.DocumentLoadCompleteEvent, atspi.BusyStateEvent} {
t.Run(eventType, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.42", "/window", "/document")
activation.Source.Role, activation.Source.Name = "document web", "First page"
adapter.events <- activation
expectActivationSpeech(t, queue.spoken, "First page, document web")
<-queue.requests
load := activation
load.Type = eventType
load.Detail1 = 0
adapter.events <- load
expectActivationSpeech(t, queue.spoken, "Finished loading First page.")
request := <-queue.requests
// Exercise backend cancellation: checking queue.Interrupt alone
// misses the implicit cancellation of non-appended requests.
speaker := &fakeSpeaker{spoken: make(chan string, 1)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
if err := worker.deliver(ctx, request); err != nil {
t.Fatal(err)
}
if speaker.canceled {
t.Fatal("load completion canceled earlier speech at the backend")
}
})
}
}
func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
for _, tc := range []struct {
name string
start string
end string
}{
{"reload stopped", atspi.DocumentReloadEvent, atspi.DocumentLoadStoppedEvent},
{"busy completed", atspi.BusyStateEvent, atspi.BusyStateEvent},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/document")
focus.Source.Role, focus.Source.Name = "document web", "Example page"
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Example page, document web")
start := ownerEvent(tc.start, ":1.42", "/window", "/document")
start.Source.Role = "document web"
start.Detail1 = 1
adapter.events <- start
if tc.start == atspi.BusyStateEvent {
expectActivationSpeech(t, queue.spoken, "Loading. Please wait.")
}
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Example page, document web")
if browser.focus.Valid() || browser.ready || keys.enabled {
t.Fatal("loading retained browse focus")
}
end := ownerEvent(tc.end, ":1.42", "/window", "/new_document")
end.Source.Role, end.Source.Name = "document web", "New page"
adapter.events <- end
if tc.end == atspi.BusyStateEvent {
expectActivationSpeech(t, queue.spoken, "Finished loading New page.")
}
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New page, document web")
if browser.focus != end.Source.ID() || !browser.resolving {
t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus)
}
// Retiring the old page must not clear the replacement's ownership.
dead := focus
dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1
adapter.events <- dead
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New page, document web")
})
}
}
func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address")
entry.Source.Role, entry.Source.Name = "entry", "Address"
adapter.events <- entry
expectActivationSpeech(t, queue.spoken, "Address, entry")
for _, eventType := range []string{atspi.DocumentReloadEvent, atspi.BusyStateEvent, atspi.DocumentLoadStoppedEvent} {
event := ownerEvent(eventType, ":1.42", "/window", "/document")
event.Source.Role = "document web"
event.Detail1 = 1
event.FocusOutsideDocument = entry.Source.ID()
adapter.events <- event
}
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Address, entry")
cancel()
<-done
if browser.focus != entry.Source.ID() {
t.Fatalf("document lifecycle displaced chrome focus: %v", browser.focus)
}
}
func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
row := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/message_row")
row.Source.Role, row.Source.Name = "table row", "Message one"
adapter.events <- row
expectActivationSpeech(t, queue.spoken, "Message one, table row")
load := ownerEvent(atspi.DocumentLoadCompleteEvent, ":1.42", "/window", "/preview")
load.Source.Role = "document web"
load.FocusOutsideDocument = row.Source.ID()
adapter.events <- load
busy := load
busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0
busy.FocusOutsideDocument = atspi.ObjectID{}
adapter.events <- busy
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message one, table row")
if browser.focus != row.Source.ID() || browser.mode != review.InactiveMode || keys.enabled {
t.Fatalf("preview busy completion took message-list arrows: focus %v, mode %v, grabs %v", browser.focus, browser.mode, keys.enabled)
}
entered := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/preview")
entered.Source.Role, entered.Source.Name = "document web", "Message preview"
adapter.events <- entered
expectActivationSpeech(t, queue.spoken, "Message preview, document web")
busy.Source.Name = entered.Source.Name
adapter.events <- busy
expectActivationSpeech(t, queue.spoken, "Finished loading Message preview.")
// Speech is queued before the controller finishes adopting the document.
// Join the event loop before inspecting its state.
cancel()
<-done
if browser.focus != entered.Source.ID() {
t.Fatalf("document focus did not enter preview: %v", browser.focus)
}
}
func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) {
for _, test := range []struct {
name string
bus string
window string
deactivate bool
defunctBefore bool
defunct string
accepted bool
want string
}{
{name: "other application", bus: ":1.43", window: "/window", want: "Address, entry"},
{name: "other window", bus: ":1.42", window: "/other", want: "Address, entry"},
{name: "owner ended", bus: ":1.42", window: "/window", deactivate: true, want: "Could not find current location."},
{name: "loaded document dies", bus: ":1.42", window: "/window", defunct: "/document", accepted: true, want: "Could not find current location."},
{name: "old focus dies", bus: ":1.42", window: "/window", defunct: "/address", accepted: true, want: "Loaded page, document web"},
{name: "replacement after old focus dies", bus: ":1.42", window: "/window", defunctBefore: true, accepted: true, want: "Loaded page, document web"},
} {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, where, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/address")
focus.Source.Role, focus.Source.Name, focus.Source.Editable = "entry", "Address", true
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Address, entry")
if test.deactivate {
adapter.events <- ownerEvent(atspi.WindowDeactivatedEvent, ":1.42", "/window", "/window")
}
if test.defunctBefore {
dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, "/address")
dead.Detail1 = 1
adapter.events <- dead
}
load := ownerEvent(atspi.DocumentLoadCompleteEvent, test.bus, test.window, "/document")
load.Source.Role, load.Source.Name = "document web", "Loaded page"
adapter.events <- load
if test.accepted {
expectActivationSpeech(t, queue.spoken, "Finished loading Loaded page.")
}
if test.defunct != "" {
dead := ownerEvent(atspi.DefunctStateEvent, test.bus, test.window, test.defunct)
dead.Detail1 = 1
adapter.events <- dead
}
// The unbuffered command follows event processing, without a sleep.
where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, test.want)
cancel()
<-done
if test.want == "Could not find current location." && browser.focus.Valid() {
t.Fatalf("browser retained ended focus: %v", browser.focus)
}
if !test.accepted && !test.deactivate && browser.focus != focus.Source.ID() {
t.Fatalf("background load replaced browser focus: %v", browser.focus)
}
})
}
}
func TestBrowserAddressBarNameEventsAreIgnored(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
options := presentation.DefaultOptions()
options.EnableKeyEcho = false
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil, options)
}()
defer func() { cancel(); <-done }()
entry := atspi.Snapshot{
BusName: ":1.7", Path: "/address-bar", ApplicationName: "Chromium Variant",
Name: "Address and search bar", Role: "entry", Editable: true, Autocomplete: true, Focused: true,
}
focused := atspi.NewEvent(atspi.FocusedStateEvent, entry)
focused.Detail1 = 1
adapter.events <- focused
select {
case got := <-queue.spoken:
if got != "Address and search bar, autocomplete" {
t.Fatalf("focus speech %q", got)
}
case <-time.After(time.Second):
t.Fatal("missing address bar focus speech")
}
siteInformation := entry
siteInformation.Path = "/site-information"
siteInformation.Name = "View site information"
siteInformation.Role = "image map"
siteInformation.Editable = false
siteInformation.Autocomplete = false
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, siteInformation)
typed := entry
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, typed, "c")
select {
case got := <-queue.spoken:
t.Fatalf("unexpected typing speech %q", got)
case <-time.After(30 * time.Millisecond):
}
suggestion := atspi.Snapshot{
BusName: ":1.7", Path: "/suggestions/first", ApplicationName: "Chromium Variant",
Name: "stormux.org", Role: "list item", Selected: true, AutocompletePopup: true,
}
selected := atspi.NewEvent("object:state-changed:selected", suggestion)
selected.Detail1 = 1
tracker.Observe(input.NewKeyEvent(0, true, "Down"))
adapter.events <- selected
select {
case got := <-queue.spoken:
if got != "stormux.org, list item, selected" {
t.Fatalf("completion speech %q", got)
}
case <-time.After(time.Second):
t.Fatal("missing completion speech")
}
// A focus event for the same popup item is another route to the same
// presentation in Chromium and must not repeat it.
adapter.events <- atspi.NewEvent(atspi.FocusedStateEvent, suggestion)
suggestionName := suggestion
suggestionName.Name = "stormux.org suggestion"
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName)
adapter.events <- selected
select {
case got := <-queue.spoken:
t.Fatalf("unexpected duplicate completion or browser UI name speech %q", got)
case <-time.After(30 * time.Millisecond):
}
select {
case got := <-queue.spoken:
t.Fatalf("unexpected browser UI name speech %q", got)
case <-time.After(30 * time.Millisecond):
}
if !isBrowserUINameNoise(atspi.NewEvent(atspi.AccessibleNameEvent, suggestionName), suggestion) {
t.Fatal("focused autocomplete popup name event was not classified as noise")
}
}
func TestBrowserNameChangesBeforeDocumentFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event)}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
// A new browser can send these before its first focus event. The last
// focused application and address-bar state cannot identify their owner.
for _, role := range []string{"button", "page tab", "frame", "static", "heading"} {
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{
BusName: ":1.7", Path: "/changed", ToolkitName: "Chromium", Role: role, Name: "Background update",
})
}
// This focus announcement synchronizes the event loop and must be first.
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{
BusName: ":1.7", Path: "/document", Role: "document web", Name: "Example page",
})
select {
case got := <-queue.spoken:
if got != "Example page, document web" {
t.Fatalf("page focus was preceded by name noise: %q", got)
}
case <-time.After(time.Second):
t.Fatal("missing page focus")
}
// Preserve ordinary toolkit name changes and actual browser focus events.
for _, toolkit := range []string{"gtk", "Qt", ""} {
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{ToolkitName: toolkit})
if isBrowserUINameNoise(event, atspi.Snapshot{}) {
t.Fatalf("filtered non-web toolkit %q", toolkit)
}
}
if isBrowserUINameNoise(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{ToolkitName: "Chromium"}), atspi.Snapshot{}) {
t.Fatal("filtered browser focus")
}
}
func TestEditableEntryEchoExcludesPasswordAndReadOnlyContent(t *testing.T) {
for _, tc := range []struct {
snapshot atspi.Snapshot
want bool
}{
{atspi.Snapshot{Role: "entry", Editable: true}, true},
{atspi.Snapshot{Role: "text", Editable: true}, true},
{atspi.Snapshot{Role: "password text", Editable: true}, false},
{atspi.Snapshot{Role: "entry"}, false},
{atspi.Snapshot{Role: "entry", Editable: true, ReadOnly: true}, false},
{atspi.Snapshot{Role: "paragraph"}, false},
} {
if got := speaksTextChanges(tc.snapshot); got != tc.want {
t.Fatalf("%+v: %v", tc.snapshot, got)
}
}
}
func TestSystemTextMutationsDoNotSpeakWebContainers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"}
section := atspi.Snapshot{BusName: ":1.7", Path: "/section", Role: "section"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, page)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent+":system", section, "new page content")
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent+":system", section, "old page content")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"})
for _, want := range []string{"Example page, document web", "End, button"} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
}
func TestAutocompleteRetainsTypedPrefix(t *testing.T) {
entry := atspi.Snapshot{BusName: ":1.7", Path: "/entry", Role: "entry", Editable: true, Autocomplete: true, SelectionStart: 1, SelectionEnd: 15, CompletionPrefixCharacter: "b"}
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "rave://version")
event.Detail1 = 1
echo := &autocompleteEcho{}
activity := input.Activity{Intent: input.TypingIntent, Sequence: 1}
if got := echo.insertion(event, activity); got != "b" {
t.Fatalf("retained prefix: %q", got)
}
if got := echo.insertion(event, activity); got != "" {
t.Fatalf("duplicate completion: %q", got)
}
activity.Sequence++
entry.SelectionStart = 2
entry.CompletionPrefixCharacter = "r"
event = atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "ave://version")
event.Detail1 = 2
if got := echo.insertion(event, activity); got != "r" {
t.Fatalf("extended prefix: %q", got)
}
// A separately reported typed character already covers the completion.
activity.Sequence++
typed := atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "r")
typed.Detail1 = 1
if got := echo.insertion(typed, activity); got != "r" {
t.Fatalf("typed letter: %q", got)
}
if got := echo.insertion(event, activity); got != "" {
t.Fatalf("completion duplicated typed letter: %q", got)
}
}
func TestBackgroundAddressChangesDoNotInterruptDocument(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
entry := atspi.Snapshot{BusName: ":1.7", Path: "/address", Role: "entry", Editable: true, Autocomplete: true, Focused: true}
page := atspi.Snapshot{BusName: ":1.7", Path: "/page", Role: "document web", Name: "Example page"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, entry)
// Focus state can change before the page's focus event arrives.
entry.Focused = false
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "automatic redirect")
adapter.events <- atspi.NewEvent(atspi.FocusEvent, page)
// Even a stale focused bit must not override the owned document focus.
entry.Focused = true
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "previous address")
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, "new address")
marker := atspi.Snapshot{Path: "/marker", Role: "button", Name: "End"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, marker)
for _, want := range []string{"autocomplete", "Example page, document web", "End, button"} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
}