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