Bring the fragmented-line browser fixture into agreement with current browse navigation. Rich editors are read through their text descendants, so Down from the preceding heading must read "Review the code." directly. Remove the stale empty editor-control stop while retaining forward and reverse line navigation, character and word movement, Read All, and inline Braille-routing assertions. Fix Firefox's missing announcement when creating a second tab. Firefox can emit selection events before the new tab enters its accessible child list, causing the native selected-child lookup to return nothing. Use the subsequent child-added event only when its target is a live, freshly selected browser-chrome tab with the emitting tab list as its direct parent. Keep document-authored tabs and unselected background additions excluded, retain foreground filtering and tab-identity deduplication, and release native references on the GLib owner. Normalize child-added origin suffixes such as :system before event dispatch, preserving the original native type in diagnostics. Rename the shared mutation normalizer to reflect its expanded scope and update its callers, including the Pidgin insertion check. Extend the normalization regression to cover child additions and reject unrelated event names. Firefox keeps its normal animations and all existing tab-announcement and address-editing assertions. Make private-session teardown tolerate the document portal's asynchronous FUSE unmount after D-Bus exits. Retry only ENOTCONN and EBUSY cleanup failures for at most five seconds. Keep unrelated errors and persistent mount failures visible. Add regressions for transient teardown, preservation of a failing child's exit status, unrelated cleanup errors, and the cleanup deadline. Runtime directories and accessibility buses remain private, with no init-system dependency. Update browser verification notes and the parity ledger with the fixes, evidence, regression risks, and concrete live acceptance procedures. Validation: - All 33 Chromium browser proofs passed in the final combined run. - Firefox baseline and tab checks passed repeatedly with normal animations. - All eight private-session isolation and cleanup regressions passed. - AT-SPI, app, and review package race suites passed during development; focused race regressions passed again after the final event-normalization change. - Go vet passed for internal/atspi, internal/app, and internal/review. - A separate reader binary built successfully. - Parity-ledger validation passed for 75 rows; git diff --check passed.
248 lines
9.6 KiB
Go
248 lines
9.6 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package atspi
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestShouldSpeakEvent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
eventType string
|
|
detail1 int
|
|
want bool
|
|
}{
|
|
{name: "focus event", eventType: FocusEvent, detail1: 0, want: true},
|
|
{name: "focused state", eventType: FocusedStateEvent, detail1: 1, want: true},
|
|
{name: "unfocused state", eventType: FocusedStateEvent, detail1: 0, want: false},
|
|
{name: "window activation", eventType: WindowActivatedEvent, detail1: 0, want: true},
|
|
{name: "value changed", eventType: ValueChangedEvent, detail1: 0, want: true},
|
|
{name: "other state", eventType: "object:state-changed:checked", detail1: 1, want: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := shouldSpeakEvent(test.eventType, test.detail1); got != test.want {
|
|
t.Fatalf("shouldSpeakEvent(%q, %d) = %v, want %v", test.eventType, test.detail1, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestObjectChangeOriginIsCanonicalized(t *testing.T) {
|
|
for _, test := range []struct{ incoming, want string }{
|
|
{TextInsertedEvent + ":system", TextInsertedEvent},
|
|
{TextDeletedEvent + ":system", TextDeletedEvent},
|
|
{TextInsertedEvent, TextInsertedEvent},
|
|
{TextDeletedEvent, TextDeletedEvent},
|
|
{ChildrenAddedEvent + ":system", ChildrenAddedEvent},
|
|
{ChildrenAddedEvent, ChildrenAddedEvent},
|
|
{ChildrenRemovedEvent + ":system", ChildrenRemovedEvent + ":system"},
|
|
{"object:children-changed:additional", "object:children-changed:additional"},
|
|
{"object:text-caret-moved", "object:text-caret-moved"},
|
|
{"object:text-changed:insertion", "object:text-changed:insertion"},
|
|
} {
|
|
if got := canonicalObjectChangeEventType(test.incoming); got != test.want {
|
|
t.Errorf("canonicalObjectChangeEventType(%q) = %q, want %q", test.incoming, got, test.want)
|
|
}
|
|
if got := NewEvent(test.incoming, Snapshot{}).Type; got != test.want {
|
|
t.Errorf("NewEvent(%q).Type = %q, want %q", test.incoming, got, test.want)
|
|
}
|
|
if test.incoming != test.want && NewEvent(test.incoming, Snapshot{}).NativeType != test.incoming {
|
|
t.Errorf("NewEvent(%q) lost its native type", test.incoming)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFirefoxChromeAutocompleteBoundary(t *testing.T) {
|
|
address := Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true, HasPopup: true}
|
|
if !isFirefoxChromeAutocomplete(address, false) {
|
|
t.Fatal("Firefox chrome address bar was not recognized")
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
snapshot Snapshot
|
|
inDocument bool
|
|
}{
|
|
{"page form", address, true},
|
|
{"Thunderbird", Snapshot{ApplicationProcess: "thunderbird", Role: "combo box", Editable: true, HasPopup: true}, false},
|
|
{"Chromium", Snapshot{ApplicationProcess: "chromium", Role: "combo box", Editable: true, HasPopup: true}, false},
|
|
{"read only", Snapshot{ApplicationProcess: "firefox", Role: "combo box", ReadOnly: true, Editable: true, HasPopup: true}, false},
|
|
{"no popup", Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true}, false},
|
|
} {
|
|
if isFirefoxChromeAutocomplete(test.snapshot, test.inDocument) {
|
|
t.Errorf("%s was classified as Firefox chrome autocomplete", test.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdapterRegistersWindowLifecycle(t *testing.T) {
|
|
for _, wanted := range []string{WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent} {
|
|
found := false
|
|
for _, eventType := range eventTypes {
|
|
if eventType == wanted {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("window lifecycle event %q is not registered", wanted)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewSnapshotSanitizesFields(t *testing.T) {
|
|
snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", " Generic\r\nname ", "text frame", "Invalid: \xff", "Current\r\nline ")
|
|
if snapshot.Path != "/org/a11y/atspi/accessible/1" {
|
|
t.Fatalf("path = %q", snapshot.Path)
|
|
}
|
|
if snapshot.Name != "Generic name" {
|
|
t.Fatalf("name = %q, want %q", snapshot.Name, "Generic name")
|
|
}
|
|
if snapshot.Description != "Invalid: �" {
|
|
t.Fatalf("description = %q, want %q", snapshot.Description, "Invalid: �")
|
|
}
|
|
}
|
|
|
|
func TestObjectIDIncludesBusNameAndPath(t *testing.T) {
|
|
snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", "Example", "push button", "", "")
|
|
snapshot.BusName = ":1.42"
|
|
if got := snapshot.ID(); got != (ObjectID{BusName: ":1.42", Path: "/org/a11y/atspi/accessible/1"}) {
|
|
t.Fatalf("object ID = %#v", got)
|
|
}
|
|
if NewObjectID(" :1.42\n", " /org/a11y/atspi/accessible/1 ") != snapshot.ID() {
|
|
t.Fatal("new object ID was not sanitized")
|
|
}
|
|
if !(ObjectID{BusName: "org.a11y.atspi.Registry", Path: "/org/a11y/atspi/accessible/root"}).Valid() {
|
|
t.Fatal("well-known registry identity is invalid")
|
|
}
|
|
for _, invalid := range []ObjectID{
|
|
{BusName: "missing-dot", Path: "/object"},
|
|
{BusName: "7org.example", Path: "/object"},
|
|
{BusName: "org.example", Path: "relative"},
|
|
{BusName: "org.example", Path: "/invalid path"},
|
|
{BusName: "org.example", Path: "/double//separator"},
|
|
} {
|
|
if invalid.Valid() {
|
|
t.Fatalf("invalid identity accepted: %#v", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewSnapshotLimitsFieldLength(t *testing.T) {
|
|
for _, character := range []string{"é", "€", "😀"} {
|
|
t.Run(character, func(t *testing.T) {
|
|
snapshot := NewSnapshot("", strings.Repeat(character, 5000), "", "", "")
|
|
if len(snapshot.Name) > maxSnapshotFieldLength {
|
|
t.Fatalf("name length = %d, want <= %d", len(snapshot.Name), maxSnapshotFieldLength)
|
|
}
|
|
if !strings.HasSuffix(snapshot.Name, character) {
|
|
t.Fatal("field truncation split a UTF-8 rune")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewSnapshotWithCaretCharacter(t *testing.T) {
|
|
snapshot := NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ls", "l",
|
|
)
|
|
if snapshot.CaretCharacter != "l" {
|
|
t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, "l")
|
|
}
|
|
}
|
|
|
|
func TestNewSnapshotPreservesCaretSpace(t *testing.T) {
|
|
snapshot := NewSnapshotWithCaretCharacter("", "", "", "", "", " space ")
|
|
if snapshot.CaretCharacter != " " {
|
|
t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, " ")
|
|
}
|
|
}
|
|
|
|
func TestNewEventWithPayloadSanitizesPayload(t *testing.T) {
|
|
event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/terminal", "", "terminal", "", ""), "l\r\n")
|
|
if event.Type != TextInsertedEvent {
|
|
t.Fatalf("type = %q, want %q", event.Type, TextInsertedEvent)
|
|
}
|
|
if event.Payload != "l" {
|
|
t.Fatalf("payload = %q, want %q", event.Payload, "l")
|
|
}
|
|
}
|
|
|
|
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 != " " {
|
|
t.Fatalf("payload = %q, want one space", event.Payload)
|
|
}
|
|
}
|
|
|
|
func TestNewEventWithPayloadPreservesTextDelimiters(t *testing.T) {
|
|
snapshot := NewSnapshot("/accessible/editor", "", "text", "", "")
|
|
for input, want := range map[string]string{"\t": "\t", "\n": "\n", "\r": "\n", "\r\n": "\n"} {
|
|
event := NewEventWithPayload(TextInsertedEvent, snapshot, input)
|
|
if event.Payload != want {
|
|
t.Fatalf("payload for %q = %q, want %q", input, event.Payload, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeTextContextPreservesBoundariesAndLimitsLength(t *testing.T) {
|
|
context := sanitizeTextContext("First. Second. \r\n")
|
|
if context != "First. Second. \n\n" {
|
|
t.Fatalf("context = %q", context)
|
|
}
|
|
context = sanitizeTextContext(strings.Repeat("é", maxSnapshotFieldLength))
|
|
if len(context) > maxSnapshotFieldLength || !strings.HasPrefix(context, "é") {
|
|
t.Fatalf("bounded context has %d bytes and invalid rune boundary", len(context))
|
|
}
|
|
}
|
|
|
|
func TestNewManagedDescendantEventPreservesOrigin(t *testing.T) {
|
|
source := NewSnapshot("/accessible/result", "Result", "table cell", "", "")
|
|
event := NewManagedDescendantEvent(SelectionChangedEvent, " /accessible/list\n", source)
|
|
if event.Source.Path != "/accessible/result" {
|
|
t.Fatalf("source path = %q", event.Source.Path)
|
|
}
|
|
if event.OriginPath != "/accessible/list" {
|
|
t.Fatalf("origin path = %q", event.OriginPath)
|
|
}
|
|
}
|
|
func TestBrowserChromeTabExcludesDocumentWidgets(t *testing.T) {
|
|
for _, toolkit := range []string{"Chromium", "Gecko", "WebKitGTK"} {
|
|
tab := Snapshot{Role: "page tab", ToolkitName: toolkit}
|
|
if !isBrowserChromeTab(tab, false) || isBrowserChromeTab(tab, true) {
|
|
t.Fatalf("chrome/document classification for %s", toolkit)
|
|
}
|
|
}
|
|
if isBrowserChromeTab(Snapshot{Role: "page tab", ToolkitName: "gtk"}, false) ||
|
|
isBrowserChromeTab(Snapshot{Role: "button", ToolkitName: "Chromium"}, false) {
|
|
t.Fatal("ordinary application control classified as browser tab")
|
|
}
|
|
}
|